Merge pull request 'Integrate swipe and d-pad to transit entry_view' (#2) from develop into main

Reviewed-on: https://git.lan/joshlaymon/Illustrations/pulls/2
This commit is contained in:
Joshua Laymon 2025-09-07 17:58:34 +00:00
commit 3b802f2e34
5 changed files with 283 additions and 353 deletions

View File

@ -0,0 +1,11 @@
/* Settings: Swipe navigation toggle (client-side, per device) */
(function(){
document.addEventListener('DOMContentLoaded', function(){
const t = document.getElementById('swipeNavToggle');
if (!t) return;
try { t.checked = (localStorage.getItem('swipeNavEnabled') !== 'false'); } catch(e){}
t.addEventListener('change', () => {
try { localStorage.setItem('swipeNavEnabled', t.checked ? 'true' : 'false'); } catch(e){}
});
});
})();

170
web/static/js/swipe-nav.js Normal file
View File

@ -0,0 +1,170 @@
/* swipe-nav.js
Mobile/desktop swipe & keyboard navigation for entry view.
- One navigation per gesture
- Edge protection: horizontal intent + distance + max duration
- Cooldown prevents multi-advance on long swipes
- Keyboard: Left/Right arrows (when not typing)
*/
(function () {
// Bail if not on an entry page with forms
const prevForm = document.getElementById("navPrevForm");
const nextForm = document.getElementById("navNextForm");
if (!prevForm && !nextForm) return;
// Prefer a dedicated swipe zone if present; fall back to body
const zone = document.getElementById("swipeZone") || document.body;
// State
let tracking = false;
let startX = 0, startY = 0, startT = 0, pointerId = null;
let didNavigate = false;
let cooldown = false;
// Tunables
const MIN_X = 48; // px horizontal distance
const MAX_ANGLE = 30; // deg off pure horizontal
const MAX_MS = 650; // must complete within this time
const COOLDOWN_MS = 450; // after we navigate, ignore further moves briefly
// Helpers
function degrees(dx, dy) {
const a = Math.atan2(Math.abs(dy), Math.abs(dx)) * (180 / Math.PI);
return a; // 0 = pure horizontal
}
function now() { return performance.now ? performance.now() : Date.now(); }
function reset() {
tracking = false;
pointerId = null;
startX = startY = 0;
startT = 0;
didNavigate = false;
}
function submit(form) {
if (!form || cooldown) return;
cooldown = true;
form.submit();
// If navigation is prevented (e.g., disabled button), release cooldown after a tick
setTimeout(() => { cooldown = false; }, COOLDOWN_MS);
}
// Pointer events if supported (best across devices). Fallback to touch.
const usePointer = "PointerEvent" in window;
function onDown(e) {
if (cooldown) return;
// Ignore right click / secondary
if (usePointer && e.pointerType === "mouse" && e.button !== 0) return;
tracking = true;
didNavigate = false;
const pt = usePointer ? e : (e.touches && e.touches[0]);
if (!pt) return;
pointerId = usePointer ? e.pointerId : pt.identifier;
startX = pt.clientX;
startY = pt.clientY;
startT = now();
// Once we suspect horizontal motion, we'll call preventDefault in move
}
function onMove(e) {
if (!tracking || didNavigate || cooldown) return;
const pt = usePointer ? e : (e.touches && e.touches[0]);
if (!pt) return;
if (usePointer && e.pointerId !== pointerId) return;
const dx = pt.clientX - startX;
const dy = pt.clientY - startY;
// If user is panning mostly horizontally, stop the browser from doing a horizontal scroll/jiggle
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
// In touch events you must preventDefault during move to suppress scroll
if (e.cancelable) e.preventDefault();
}
}
function onUp(e) {
if (!tracking || didNavigate || cooldown) { reset(); return; }
let pt;
if (usePointer) {
if (e.pointerId !== pointerId) return reset();
pt = e;
} else {
// On touchend, touches[] is empty; use changedTouches[]
pt = (e.changedTouches && e.changedTouches[0]) || null;
if (!pt) return reset();
}
const dx = pt.clientX - startX;
const dy = pt.clientY - startY;
const dt = now() - startT;
const ang = degrees(dx, dy);
const horizontalEnough = ang <= MAX_ANGLE;
// Must be a deliberate, reasonably quick horizontal swipe
if (Math.abs(dx) >= MIN_X && horizontalEnough && dt <= MAX_MS) {
didNavigate = true;
if (dx < 0) {
// left swipe -> NEXT
const btn = nextForm && nextForm.querySelector("button[disabled]");
if (!btn) submit(nextForm);
} else {
// right swipe -> PREV
const btn = prevForm && prevForm.querySelector("button[disabled]");
if (!btn) submit(prevForm);
}
if (e.cancelable) e.preventDefault();
}
reset();
}
function onCancel() { reset(); }
if (usePointer) {
zone.addEventListener("pointerdown", onDown, { passive: true });
zone.addEventListener("pointermove", onMove, { passive: false });
zone.addEventListener("pointerup", onUp, { passive: false });
zone.addEventListener("pointercancel", onCancel, { passive: true });
zone.addEventListener("pointerleave", onCancel, { passive: true });
} else {
zone.addEventListener("touchstart", onDown, { passive: true });
zone.addEventListener("touchmove", onMove, { passive: false });
zone.addEventListener("touchend", onUp, { passive: false });
zone.addEventListener("touchcancel", onCancel, { passive: true });
}
// Keyboard support (left/right arrows) ignore if typing
document.addEventListener("keydown", (e) => {
if (cooldown) return;
const tag = (e.target && e.target.tagName) || "";
const typing = /INPUT|TEXTAREA|SELECT/.test(tag);
if (typing) return;
if (e.key === "ArrowRight") {
const btn = nextForm && nextForm.querySelector("button[disabled]");
if (!btn) { e.preventDefault(); submit(nextForm); }
} else if (e.key === "ArrowLeft") {
const btn = prevForm && prevForm.querySelector("button[disabled]");
if (!btn) { e.preventDefault(); submit(prevForm); }
}
});
// Defensive CSS: lock horizontal gestures to our handler only inside the zone
(function injectCSS() {
const css = `
#swipeZone{ touch-action: pan-y; overscroll-behavior-x: none; overflow-x: hidden; }
html, body{ overscroll-behavior-x: none; }
`;
const el = document.createElement("style");
el.textContent = css;
document.head.appendChild(el);
})();
})();

View File

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="result-wrap"> <div id="swipeZone" class="result-wrap">
<!-- Top bar: back + counter + clear Prev/Next --> <!-- Top bar: back + counter + clear Prev/Next -->
<div class="result-toolbar"> <div class="result-toolbar">
@ -14,12 +14,12 @@
{% endif %} {% endif %}
</div> </div>
<div class="rt-right"> <div class="rt-right">
<form method="get" action="{% url 'nav_prev' %}" class="inline"> <form id="navPrevForm" method="get" action="{% url 'nav_prev' %}" class="inline">
<!-- send current zero-based index (position-1); view will subtract 1 --> <!-- send current zero-based index (position-1); view will subtract 1 -->
<input type="hidden" name="i" value="{{ position|add:'-1' }}"> <input type="hidden" name="i" value="{{ position|add:'-1' }}">
<button class="btn btn-lg" {% if position <= 1 %}disabled{% endif %}> Prev</button> <button class="btn btn-lg" {% if position <= 1 %}disabled{% endif %}> Prev</button>
</form> </form>
<form method="get" action="{% url 'nav_next' %}" class="inline"> <form id="navNextForm" method="get" action="{% url 'nav_next' %}" class="inline">
<!-- send current zero-based index (position-1); view will add 1 --> <!-- send current zero-based index (position-1); view will add 1 -->
<input type="hidden" name="i" value="{{ position|add:'-1' }}"> <input type="hidden" name="i" value="{{ position|add:'-1' }}">
<button class="btn btn-lg btn-primary" {% if position >= count %}disabled{% endif %}>Next </button> <button class="btn btn-lg btn-primary" {% if position >= count %}disabled{% endif %}>Next </button>
@ -35,27 +35,25 @@
<path d="M5.646 3.646a.5.5 0 0 1 .708 0L8 5.293l1.646-1.647a.5.5 0 0 1 .708.708L8.354 6.354a.5.5 0 0 1-.708 0L5.646 4.354a.5.5 0 0 1 0-.708z"/> <path d="M5.646 3.646a.5.5 0 0 1 .708 0L8 5.293l1.646-1.647a.5.5 0 0 1 .708.708L8.354 6.354a.5.5 0 0 1-.708 0L5.646 4.354a.5.5 0 0 1 0-.708z"/>
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v5A1.5 1.5 0 0 0 4.5 14h7A1.5 1.5 0 0 0 13 12.5v-5A1.5 1.5 0 0 0 11.5 6H10a.5.5 0 0 0 0 1h1.5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-5a.5.5 0 0 1 .5-.5H6a.5.5 0 0 0 0-1H4.5z"/> <path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v5A1.5 1.5 0 0 0 4.5 14h7A1.5 1.5 0 0 0 13 12.5v-5A1.5 1.5 0 0 0 11.5 6H10a.5.5 0 0 0 0 1h1.5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-5a.5.5 0 0 1 .5-.5H6a.5.5 0 0 0 0-1H4.5z"/>
</svg> </svg>
Share
</span> </span>
</button> </button>
<!-- Share button ... --> <!-- Share button ... -->
<button class="btn btn-secondary" id="ttsBtn" <button class="btn btn-secondary" id="ttsBtn"
title="Read aloud" title="Read aloud"
aria-label="Read illustration out loud" aria-label="Read illustration out loud"
{% if tts_url %} data-tts-url="{{ tts_url }}"{% endif %} {% if tts_url %} data-tts-url="{{ tts_url }}"{% endif %}
data-illustration="{{ entry.illustration|default_if_none:''|escapejs }}" data-illustration="{{ entry.illustration|default_if_none:''|escapejs }}"
data-application="{{ entry.application|default_if_none:''|escapejs }}"> data-application="{{ entry.application|default_if_none:''|escapejs }}">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="vertical-align:middle;"> <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="vertical-align:middle;">
<path d="M3 10v4h4l5 4V6L7 10H3z"></path> <path d="M3 10v4h4l5 4V6L7 10H3z"></path>
<path d="M16.5 12c0-1.77-1-3.29-2.5-4.03v8.06A4.49 4.49 0 0 0 16.5 12z"></path> <path d="M16.5 12c0-1.77-1-3.29-2.5-4.03v8.06A4.49 4.49 0 0 0 16.5 12z"></path>
<path d="M14 3.23v2.06c3.39.49 6 3.39 6 6.71s-2.61 6.22-6 6.71v2.06c4.45-.52 8-4.3 8-8.77s-3.55-8.25-8-8.77z"></path> <path d="M14 3.23v2.06c3.39.49 6 3.39 6 6.71s-2.61 6.22-6 6.71v2.06c4.45-.52 8-4.3 8-8.77s-3.55-8.25-8-8.77z"></path>
</svg> </svg>
</button> </button>
<!-- Edit button ... --> <!-- Edit/Delete for staff -->
{% if user.is_authenticated and user.is_staff %} {% if user.is_authenticated and user.is_staff %}
<!-- Just the label changed to 'Edit'; link unchanged -->
<a class="btn btn-outline" href="{% url 'entry_edit' entry.id %}">Edit</a> <a class="btn btn-outline" href="{% url 'entry_edit' entry.id %}">Edit</a>
<a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a> <a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
{% endif %} {% endif %}
@ -92,7 +90,7 @@
<div class="section"> <div class="section">
<div class="section-label">Application</div> <div class="section-label">Application</div>
<div class="section-body lead-text" id="application-text"> <div class="section-body lead-text" id="application-text">
{{ entry.application|linebreaksbr|default:"—" }} {{ entry.application|linebreaksbr|default:"—" }}
</div> </div>
</div> </div>
@ -132,7 +130,6 @@
<div class="meta-label">Talk</div> <div class="meta-label">Talk</div>
<div class="meta-value" id="talk_title-text"> <div class="meta-value" id="talk_title-text">
{% if entry.talk_title %}{{ entry.talk_title }}{% else %}—{% endif %} {% if entry.talk_title %}{{ entry.talk_title }}{% else %}—{% endif %}
{# Talk Number pill (staff-only link) #}
{% if entry.talk_number %} {% if entry.talk_number %}
{% if user.is_authenticated and user.is_staff and talk_pdf_url %} {% if user.is_authenticated and user.is_staff and talk_pdf_url %}
<a href="{{ talk_pdf_url }}" target="_blank" rel="noopener" <a href="{{ talk_pdf_url }}" target="_blank" rel="noopener"
@ -185,6 +182,9 @@
</script> </script>
<style> <style>
/* prevent horizontal jiggle while swiping */
#swipeZone{ touch-action: pan-y; overscroll-behavior-x: none; overflow-x: hidden; }
.subject-pills{ .subject-pills{
display:flex; display:flex;
flex-wrap:wrap; flex-wrap:wrap;
@ -206,22 +206,16 @@
background:#e2effc; background:#e2effc;
border-color:#c9def5; border-color:#c9def5;
} }
/* Subject chip gets a hit → color the whole chip */
.chip-subject.chip-hit{ .chip-subject.chip-hit{
background:#f8e3c5; /* your chosen highlight color */ background:#f8e3c5;
border-color:#e0b98e; border-color:#e0b98e;
color:#111; color:#111;
} }
/* Light gray (peach) highlight for inline text hits */
.mark-hit { .mark-hit {
background: #f8e3c5; background: #f8e3c5;
border-radius: 3px; border-radius: 3px;
padding: 0 .15em; padding: 0 .15em;
} }
/* Subtle invalid style for individual Scripture pills */
.chip-link.scripture-pill-invalid { .chip-link.scripture-pill-invalid {
background-color: hsl(0 80% 94% / 0.75); background-color: hsl(0 80% 94% / 0.75);
border-color: #efc1c1; border-color: #efc1c1;
@ -232,16 +226,13 @@
// Robust copy to clipboard that works on HTTP and HTTPS // Robust copy to clipboard that works on HTTP and HTTPS
(function () { (function () {
function copyText(text) { function copyText(text) {
// Try modern API first
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text); return navigator.clipboard.writeText(text);
} }
// Fallback: temporary textarea
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
try { try {
var ta = document.createElement('textarea'); var ta = document.createElement('textarea');
ta.value = text; ta.value = text;
// Avoid scrolling to bottom
ta.style.position = 'fixed'; ta.style.position = 'fixed';
ta.style.top = '-1000px'; ta.style.top = '-1000px';
ta.style.left = '-1000px'; ta.style.left = '-1000px';
@ -304,7 +295,7 @@
})(); })();
</script> </script>
<!-- ===== TTS with play/stop toggle (ONLY this block changed) ===== --> <!-- ===== TTS with play/stop toggle ===== -->
<script> <script>
(function(){ (function(){
const ttsBtn = document.getElementById('ttsBtn'); const ttsBtn = document.getElementById('ttsBtn');
@ -312,141 +303,53 @@
const TTS_URL = "{{ tts_url|default:'' }}"; // empty for non-staff → browser TTS const TTS_URL = "{{ tts_url|default:'' }}"; // empty for non-staff → browser TTS
// -------- persistent audio + helpers -------- let audioEl = null, currentURL = null, playing = false, fetchCtrl = null;
let audioEl = null; function ensureAudio(){ if (!audioEl){ audioEl = new Audio(); audioEl.setAttribute('playsinline',''); audioEl.preload='auto';
let currentURL = null; // objectURL for OpenAI response audioEl.addEventListener('ended', ()=>{ playing=false; cleanupURL(); }); } return audioEl; }
let playing = false; function cleanupURL(){ if (currentURL){ URL.revokeObjectURL(currentURL); currentURL=null; } }
let fetchCtrl = null; function stopAll(){ try{ if(fetchCtrl) fetchCtrl.abort(); }catch(_){} fetchCtrl=null; if(audioEl){ audioEl.pause(); audioEl.currentTime=0; } cleanupURL(); playing=false; }
function ensureAudio(){
if (!audioEl) {
audioEl = new Audio();
audioEl.setAttribute('playsinline',''); // iOS
audioEl.preload = 'auto';
audioEl.addEventListener('ended', () => { playing = false; cleanupURL(); });
audioEl.addEventListener('pause', () => { /* keep playing=false only if really paused */ });
}
return audioEl;
}
function cleanupURL(){
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
}
function stopAll(){
try { if (fetchCtrl) fetchCtrl.abort(); } catch(_){}
fetchCtrl = null;
if (audioEl) { audioEl.pause(); audioEl.currentTime = 0; }
cleanupURL();
playing = false;
}
// -------- text builders --------
function buildCombinedText(){ function buildCombinedText(){
const ill = (document.getElementById('illustration-text')?.innerText || '').trim(); const ill = (document.getElementById('illustration-text')?.innerText || '').trim();
const app = (document.getElementById('application-text')?.innerText || '').trim(); const app = (document.getElementById('application-text')?.innerText || '').trim();
const illP = ill && /[.!?…]$/.test(ill) ? ill : (ill ? ill + '.' : ''); const illP = ill && /[.!?…]$/.test(ill) ? ill : (ill ? ill + '.' : '');
return [illP, app].filter(Boolean).join(' ') || 'No text available for this illustration.'; return [illP, app].filter(Boolean).join(' ') || 'No text available for this illustration.';
} }
function speakBrowserTTS(){ function speakBrowserTTS(){
const text = buildCombinedText(); const text = buildCombinedText();
if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) { if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) throw new Error('Browser TTS not supported.');
throw new Error('Browser TTS not supported.');
}
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text); const u = new SpeechSynthesisUtterance(text); u.rate=1.0; u.pitch=1.0; u.volume=1.0; speechSynthesis.speak(u); playing=true;
u.rate = 1.0; u.pitch = 1.0; u.volume = 1.0;
speechSynthesis.speak(u);
playing = true;
} }
async function playOpenAITTS(){ async function playOpenAITTS(){
stopAll(); stopAll(); fetchCtrl = new AbortController();
fetchCtrl = new AbortController();
const r = await fetch(TTS_URL, { credentials:'same-origin', cache:'no-store', signal: fetchCtrl.signal }); const r = await fetch(TTS_URL, { credentials:'same-origin', cache:'no-store', signal: fetchCtrl.signal });
if (!r.ok) { if (!r.ok) { const preview = await r.text().catch(()=> String(r.status)); throw new Error(`HTTP ${r.status}: ${preview.slice(0,200)}`); }
const preview = await r.text().catch(()=> String(r.status)); const ct = (r.headers.get('content-type') || '').toLowerCase(); if (!ct.startsWith('audio/')) {
throw new Error(`HTTP ${r.status}: ${preview.slice(0,200)}`); const preview = await r.text().catch(()=> '(non-audio response)'); throw new Error(`Unexpected content-type "${ct}". Preview: ${preview.slice(0,200)}`); }
} const blob = await r.blob(); currentURL = URL.createObjectURL(blob);
const ct = (r.headers.get('content-type') || '').toLowerCase(); const a = ensureAudio(); a.src = currentURL; a.currentTime = 0; a.muted = false;
if (!ct.startsWith('audio/')) { try { await a.play(); playing = true; } catch (err) {
const preview = await r.text().catch(()=> '(non-audio response)'); if (err && (err.name === 'NotAllowedError' || /not allowed|denied permission/i.test(err.message))) {
throw new Error(`Unexpected content-type "${ct}". Preview: ${preview.slice(0,200)}`);
}
const blob = await r.blob();
currentURL = URL.createObjectURL(blob);
const a = ensureAudio();
a.src = currentURL;
a.currentTime = 0;
a.muted = false;
try {
await a.play();
playing = true;
} catch (err) {
if (err && (err.name === 'NotAllowedError' ||
/not allowed|denied permission/i.test(err.message))) {
showToast("Audio was blocked. Make sure the phone isn't on Silent and tap the speaker again."); showToast("Audio was blocked. Make sure the phone isn't on Silent and tap the speaker again.");
} else { } else { throw err; }
throw err;
}
} }
} }
ttsBtn.addEventListener('click', async () => { ttsBtn.addEventListener('click', async () => {
if (playing) { if (playing) { try { stopAll(); if ('speechSynthesis' in window) window.speechSynthesis.cancel(); } finally { playing=false; showToast("Stopped."); } return; }
try { try { ttsBtn.disabled = true; if (TTS_URL) { await playOpenAITTS(); if (playing) showToast("Playing with OpenAI TTS"); }
stopAll(); else { speakBrowserTTS(); showToast("Playing with Browser TTS"); } }
if ('speechSynthesis' in window) window.speechSynthesis.cancel(); catch (err) { showToast("TTS error: " + (err?.message || String(err))); }
} finally { finally { ttsBtn.disabled = false; }
playing = false;
showToast("Stopped.");
}
return;
}
try {
ttsBtn.disabled = true;
if (TTS_URL) {
await playOpenAITTS();
if (playing) showToast("Playing with OpenAI TTS");
} else {
speakBrowserTTS();
showToast("Playing with Browser TTS");
}
} catch (err) {
showToast("TTS error: " + (err?.message || String(err)));
} finally {
ttsBtn.disabled = false;
}
}); });
window.addEventListener('pagehide', stopAll); window.addEventListener('pagehide', stopAll);
})(); })();
</script> </script>
<style> <style>
.toast { .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
position: fixed; background: #333; color: #fff; padding: 10px 20px; border-radius: 6px;
bottom: 20px; opacity: 0; transition: opacity 0.5s ease; z-index: 9999; }
left: 50%; .toast.show { opacity: 1; }
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.5s ease;
z-index: 9999;
}
.toast.show {
opacity: 1;
}
</style> </style>
<script> <script>
@ -455,150 +358,66 @@ function showToast(message, duration = 3000) {
toast.className = "toast"; toast.className = "toast";
toast.textContent = message; toast.textContent = message;
document.body.appendChild(toast); document.body.appendChild(toast);
setTimeout(() => toast.classList.add("show"), 50); setTimeout(() => toast.classList.add("show"), 50);
setTimeout(() => { setTimeout(() => { toast.classList.remove("show");
toast.classList.remove("show"); setTimeout(() => document.body.removeChild(toast), 500); }, duration);
setTimeout(() => document.body.removeChild(toast), 500);
}, duration);
} }
</script> </script>
<!-- Highlighter: apply to ALL fields; for Subjects, color the whole chip; highlight WHOLE WORDS --> <!-- Highlighter logic (unchanged) -->
<script> <script>
(function(){ (function(){
const ALL_SELECTORS = [ const ALL_SELECTORS = ["#subject-list","#illustration-text","#application-text","#scripture-text","#source-text","#talk_title-text"];
"#subject-list",
"#illustration-text",
"#application-text",
"#scripture-text",
"#source-text",
"#talk_title-text"
];
if (document.readyState === "complete" || document.readyState === "interactive") run(); if (document.readyState === "complete" || document.readyState === "interactive") run();
else document.addEventListener("DOMContentLoaded", run, { once: true }); else document.addEventListener("DOMContentLoaded", run, { once: true });
window.addEventListener("pageshow", run); window.addEventListener("pageshow", run);
let ran = false; let ran = false;
async function run(){ async function run(){
if (ran) return; if (ran) return;
let enabled = true; let enabled = true;
try { try { const res = await fetch("/api/get-prefs/", { cache: "no-store", credentials: "same-origin" });
const res = await fetch("/api/get-prefs/", { cache: "no-store", credentials: "same-origin" }); if (res.ok) { const prefs = await res.json();
if (res.ok) { if (typeof prefs.highlight_search_hits !== "undefined") enabled = !!prefs.highlight_search_hits; } } catch(_) {}
const prefs = await res.json();
if (typeof prefs.highlight_search_hits !== "undefined") enabled = !!prefs.highlight_search_hits;
}
} catch(_) {}
if (!enabled) { ran = true; return; } if (!enabled) { ran = true; return; }
let q = (window.__lastSearchQ || "").trim(); let q = (window.__lastSearchQ || "").trim();
if (!q) { if (!q) { const dataEl = document.getElementById("last-search-data");
const dataEl = document.getElementById("last-search-data"); if (dataEl) { try { q = (JSON.parse(dataEl.textContent||"{}").q || "").trim(); } catch(_){}}}
if (dataEl) {
try { q = (JSON.parse(dataEl.textContent||"{}").q || "").trim(); } catch(_){}
}
}
if (!q) q = (localStorage.getItem("lastSearchQ") || "").trim(); if (!q) q = (localStorage.getItem("lastSearchQ") || "").trim();
if (!q) { ran = true; return; } if (!q) { ran = true; return; }
const tokens = tokenize(q).map(t => t.replaceAll("*","").replaceAll("?","")).filter(Boolean); const tokens = tokenize(q).map(t => t.replaceAll("*","").replaceAll("?","")).filter(Boolean);
if (!tokens.length) { ran = true; return; } if (!tokens.length) { ran = true; return; }
for (const sel of ALL_SELECTORS) { for (const sel of ALL_SELECTORS) {
const container = document.querySelector(sel); const container = document.querySelector(sel);
if (!container) continue; if (!container) continue;
for (const tok of tokens) highlightAll(container, tok); for (const tok of tokens) highlightAll(container, tok);
} }
ran = true; ran = true;
} }
function tokenize(s){ const out=[]; let i=0,buf="",inQ=false; while(i<s.length){const c=s[i++]; if(c==='"'){ if(inQ){ if(buf.trim()) out.push(buf); buf=""; inQ=false; } else { if(buf.trim()) out.push(buf); buf=""; inQ=true; } }
function tokenize(s) { else if(!inQ && /\s/.test(c)){ if(buf.trim()) out.push(buf), buf=""; } else { buf+=c; } } if(buf.trim()) out.push(buf); return out; }
const out = []; function makeWordRegex(){ try{ return new RegExp("(\\p{L}[\\p{L}\\p{M}\\p{N}'-]*|\\d+)", "gu"); } catch (_){ return /([A-Za-z][A-Za-z0-9'-]*|\d+)/g; } }
let i = 0, buf = "", inQ = false; function highlightAll(root, needle){
while (i < s.length) {
const c = s[i++];
if (c === '"') {
if (inQ) { if (buf.trim()) out.push(buf); buf = ""; inQ = false; }
else { if (buf.trim()) out.push(buf); buf = ""; inQ = true; }
} else if (!inQ && /\s/.test(c)) {
if (buf.trim()) out.push(buf), buf = "";
} else {
buf += c;
}
}
if (buf.trim()) out.push(buf);
return out;
}
function makeWordRegex() {
try {
return new RegExp("(\\p{L}[\\p{L}\\p{M}\\p{N}'-]*|\\d+)", "gu");
} catch (_) {
return /([A-Za-z][A-Za-z0-9'-]*|\d+)/g;
}
}
function highlightAll(root, needle) {
if (!needle) return; if (!needle) return;
const needleLower = needle.toLowerCase(); const needleLower = needle.toLowerCase();
if (root.id === "subject-list") { if (root.id === "subject-list") {
root.querySelectorAll(".chip-subject, .chip-muted").forEach(chip => { root.querySelectorAll(".chip-subject, .chip-muted").forEach(chip => {
if ((chip.textContent || "").toLowerCase().includes(needleLower)) { if ((chip.textContent || "").toLowerCase().includes(needleLower)) chip.classList.add("chip-hit");
chip.classList.add("chip-hit"); }); return;
}
});
return;
} }
const nodes=[]; const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
const nodes = []; while(w.nextNode()) nodes.push(w.currentNode);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
while (walker.nextNode()) nodes.push(walker.currentNode);
const wordRe = makeWordRegex(); const wordRe = makeWordRegex();
for (const textNode of nodes) { for (const textNode of nodes) {
const text = textNode.nodeValue; const text = textNode.nodeValue; if (!text || !text.toLowerCase().includes(needleLower)) continue;
if (!text) continue; const frag=document.createDocumentFragment(); let lastIndex=0; wordRe.lastIndex=0; let m;
if (!text.toLowerCase().includes(needleLower)) continue; while((m=wordRe.exec(text))!==null){ const start=m.index, end=start+m[0].length;
if (start>lastIndex) frag.appendChild(document.createTextNode(text.slice(lastIndex,start)));
const frag = document.createDocumentFragment();
let lastIndex = 0;
wordRe.lastIndex = 0;
let m;
while ((m = wordRe.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
if (start > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, start)));
}
const word = m[0]; const word = m[0];
if (word.toLowerCase().includes(needleLower)) { if (word.toLowerCase().includes(needleLower)) { const mark=document.createElement("mark"); mark.className="mark-hit"; mark.textContent=word; frag.appendChild(mark); }
const mark = document.createElement("mark"); else { frag.appendChild(document.createTextNode(word)); }
mark.className = "mark-hit"; lastIndex=end; }
mark.textContent = word; if (lastIndex<text.length) frag.appendChild(document.createTextNode(text.slice(lastIndex)));
frag.appendChild(mark); if (textNode.parentNode) textNode.parentNode.replaceChild(frag, textNode);
} else {
frag.appendChild(document.createTextNode(word));
}
lastIndex = end;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
if (textNode.parentNode) {
textNode.parentNode.replaceChild(frag, textNode);
}
} }
} }
})(); })();
@ -614,36 +433,31 @@ function showToast(message, duration = 3000) {
const link = document.querySelector('#source-text .js-source-link'); const link = document.querySelector('#source-text .js-source-link');
if (!link) return; if (!link) return;
const srcText = link.getAttribute('data-source') || (link.textContent || '').trim(); const srcText = link.getAttribute('data-source') || (link.textContent || '').trim();
if (SourceValidator.isWOLSource(srcText)) { if (SourceValidator.isWOLSource(srcText)) {
link.href = SourceValidator.buildWOLSearchURL(srcText); link.href = SourceValidator.buildWOLSearchURL(srcText);
link.title = (link.title ? link.title + ' • ' : '') + 'Open in WOL'; link.title = (link.title ? link.title + ' • ' : '') + 'Open in WOL';
link.classList.remove('scripture-pill-invalid'); // ensure its not red if previously set link.classList.remove('scripture-pill-invalid');
} else { } else {
// Keep Google link, but flag visually like invalid scripture
link.title = (link.title ? link.title + ' • ' : '') + 'Search on Google'; link.title = (link.title ? link.title + ' • ' : '') + 'Search on Google';
link.classList.add('scripture-pill-invalid'); link.classList.add('scripture-pill-invalid');
} }
})(); })();
</script> </script>
<!-- Scripture pill validation you already use --> <!-- Scripture pill validation -->
<script> <script>
(function () { (function () {
function validatePills() { function validatePills() {
const container = document.getElementById('scripture-text'); const container = document.getElementById('scripture-text');
if (!container) return; if (!container) return;
const pills = container.querySelectorAll('a.chip, a.chip-link'); const pills = container.querySelectorAll('a.chip, a.chip-link');
pills.forEach(pill => { pills.forEach(pill => {
const txt = (pill.textContent || '').trim(); const txt = (pill.textContent || '').trim();
if (!txt) return; if (!txt) return;
const tmp = document.createElement('input'); const tmp = document.createElement('input');
tmp.type = 'text'; tmp.type = 'text';
tmp.value = txt; tmp.value = txt;
ScriptureValidator.attach(tmp); ScriptureValidator.attach(tmp);
const isValid = tmp.classList.contains('scripture-valid'); const isValid = tmp.classList.contains('scripture-valid');
if (!isValid) { if (!isValid) {
pill.classList.add('scripture-pill-invalid'); pill.classList.add('scripture-pill-invalid');
@ -652,7 +466,6 @@ function showToast(message, duration = 3000) {
tmp.remove(); tmp.remove();
}); });
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', validatePills, { once: true }); document.addEventListener('DOMContentLoaded', validatePills, { once: true });
} else { } else {
@ -667,5 +480,9 @@ function showToast(message, duration = 3000) {
<!-- NEW: Subject pill popup + Insight/Wiki/Google/DB search --> <!-- NEW: Subject pill popup + Insight/Wiki/Google/DB search -->
<script src="{% static 'js/subject-actions.v1.js' %}"></script> <script src="{% static 'js/subject-actions.v1.js' %}"></script>
{% endblock %}
{% block extra_body %}
<!-- Swipe/keyboard navigation (mobile + desktop), client-side pref aware -->
<script src="{% static 'js/swipe-nav.js' %}"></script>
{% endblock %} {% endblock %}

View File

@ -9,34 +9,6 @@
<div class="search-row"> <div class="search-row">
<input type="text" name="q" value="{{ q }}" placeholder="Type to search…" class="search-input" autofocus> <input type="text" name="q" value="{{ q }}" placeholder="Type to search…" class="search-input" autofocus>
<button class="btn btn-primary">Search</button> <button class="btn btn-primary">Search</button>
<!-- Help button -->
<button
class="btn btn-secondary help-toggle"
type="button"
data-target="#search-help-panel"
>
Help
</button>
</div>
<!-- Help panel -->
<div id="search-help-panel" class="help-panel">
<h3>How to Use Search Operators</h3>
<ul>
<li><strong>Simple keyword</strong> — type any word to find entries that contain it.<br>
<em>Example:</em> <code>faith</code></li>
<li><strong>Phrase search</strong> — put quotes around a phrase to match it exactly.<br>
<em>Example:</em> <code>"Jehovah is my shepherd"</code></li>
<li><strong>OR search</strong> — use <code>OR</code> (uppercase).<br>
<em>Example:</em> <code>love OR kindness</code></li>
<li><strong>Exclude terms</strong> — use <code>-</code> to remove.<br>
<em>Example:</em> <code>hope -future</code></li>
<li><strong>Wildcard search</strong> — use <code>*</code>.<br>
<em>Example:</em> <code>lov*</code></li>
<li><strong>Scripture search</strong> — type a Bible book.<br>
<em>Example:</em> <code>John 3:16</code></li>
</ul>
</div> </div>
<div class="filter-row"> <div class="filter-row">
@ -225,22 +197,6 @@
}); });
}); });
// Help panel toggle (unchanged)
document.addEventListener('click', function(e){
const btn = e.target.closest('.help-toggle');
if (btn) {
const panel = document.querySelector(btn.dataset.target || '#search-help-panel');
if (panel) panel.classList.toggle('open');
return;
}
const panel = document.querySelector('#search-help-panel');
if (!panel) return;
if (panel.classList.contains('open')) {
const clickedInside = panel.contains(e.target) || e.target.closest('.help-toggle');
if (!clickedInside) panel.classList.remove('open');
}
});
// =============================== // ===============================
// No-results: show a random funny illustration // No-results: show a random funny illustration
// =============================== // ===============================
@ -254,7 +210,7 @@
`Its like looking for a matching sock. You find five that are “close enough,” but never the one you actually need.`, `Its like looking for a matching sock. You find five that are “close enough,” but never the one you actually need.`,
`Its like running into the store for peanut butter. You leave with bread, bananas, cereal, and gum—somehow no peanut butter.`, `Its like running into the store for peanut butter. You leave with bread, bananas, cereal, and gum—somehow no peanut butter.`,
`It feels like searching for your car keys when youre late. You check the counter, pockets, and finally… the fridge. Next to the milk.`, `It feels like searching for your car keys when youre late. You check the counter, pockets, and finally… the fridge. Next to the milk.`,
`Its like walking around the house holding your phone in the air, trying to catch a WiFi signal that appears and vanishes at random.`, `Its like walking around the house holding your phone in the air, trying to catch a Wi-Fi signal that appears and vanishes at random.`,
`Imagine a giant library where youre sure the book is “right here.” You scan up, down, left, right—then realize youre in the wrong aisle.`, `Imagine a giant library where youre sure the book is “right here.” You scan up, down, left, right—then realize youre in the wrong aisle.`,
`Its like following GPS: “Turn left now!” You miss it, circle the block, and the voice keeps politely “recalculating.”`, `Its like following GPS: “Turn left now!” You miss it, circle the block, and the voice keeps politely “recalculating.”`,
`Its like trying to find an umbrella on a sunny day—no luck. The moment it pours, suddenly you own five.`, `Its like trying to find an umbrella on a sunny day—no luck. The moment it pours, suddenly you own five.`,
@ -284,7 +240,7 @@
const today = new Date(); const today = new Date();
const ymd = today.getFullYear()*10000 + (today.getMonth()+1)*100 + today.getDate(); const ymd = today.getFullYear()*10000 + (today.getMonth()+1)*100 + today.getDate();
// 32bit xorshift PRNG for a good daily seed // 32-bit xorshift PRNG for a good daily seed
function xorshift32(seed){ function xorshift32(seed){
let x = seed | 0; let x = seed | 0;
x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
@ -362,19 +318,6 @@
</script> </script>
<style> <style>
/* Help panel */
.help-panel{
display:none;
margin-top:10px;
background:#fff;
border:1px solid var(--border);
border-radius:12px;
padding:14px;
box-shadow:0 4px 16px rgba(0,0,0,.06);
font-size:14px;
}
.help-panel.open{ display:block; }
/* Dropdown panel */ /* Dropdown panel */
.dropdown-panel { display:none; padding:12px; } .dropdown-panel { display:none; padding:12px; }
.dropdown-panel.open { display:block; } .dropdown-panel.open { display:block; }
@ -412,14 +355,12 @@
const fd = new FormData(form); const fd = new FormData(form);
const fields = []; const fields = [];
for (const [k, v] of fd.entries()) { for (const [k, v] of fd.entries()) {
// sel[field]=on → capture "field"
if (k.startsWith('sel[') && k.endsWith(']') && (v === 'on' || v === 'true' || v === '1')) { if (k.startsWith('sel[') && k.endsWith(']') && (v === 'on' || v === 'true' || v === '1')) {
fields.push(k.slice(4, -1)); fields.push(k.slice(4, -1));
} }
} }
const q = (qInput && qInput.value || '').trim(); const q = (qInput && qInput.value || '').trim();
// Store exactly what the user selected — NO default to ["subject"]
try { try {
localStorage.setItem('lastSearchQ', q); localStorage.setItem('lastSearchQ', q);
localStorage.setItem('lastSearchFields', JSON.stringify(fields)); localStorage.setItem('lastSearchFields', JSON.stringify(fields));
@ -428,7 +369,5 @@
})(); })();
</script> </script>
{% include "partials/announcement_modal.html" %} {% include "partials/announcement_modal.html" %}
{% endblock %} {% endblock %}

View File

@ -57,6 +57,15 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
<!-- Swipe navigation (client-side pref) -->
<div class="cc-row" style="margin-top:10px;">
<label class="cc-label">Swipe to navigate entries (mobile)</label>
<label class="switch">
<input id="swipeNavToggle" type="checkbox">
<span class="slider"></span>
</label>
</div>
</div> </div>
</section> </section>
@ -159,7 +168,7 @@
</div> </div>
</div> </div>
<!-- Toast --> <!-- Toast for Clear History -->
<div id="toast-clear-history" <div id="toast-clear-history"
style="position:fixed; right:16px; bottom:16px; padding:10px 14px; border-radius:10px; style="position:fixed; right:16px; bottom:16px; padding:10px 14px; border-radius:10px;
background:#111827; color:#fff; box-shadow:0 6px 20px rgba(0,0,0,.25); background:#111827; color:#fff; box-shadow:0 6px 20px rgba(0,0,0,.25);
@ -200,21 +209,6 @@
.swatch[data-theme="midnight"]{background:linear-gradient(135deg,#0b1220,#1c2741);} .swatch[data-theme="midnight"]{background:linear-gradient(135deg,#0b1220,#1c2741);}
.swatch[data-theme="forest"]{background:linear-gradient(135deg,#d7f3e2,#92c7a3);} .swatch[data-theme="forest"]{background:linear-gradient(135deg,#d7f3e2,#92c7a3);}
.swatch[data-theme="sandstone"]{background:linear-gradient(135deg,#f7efe4,#e4d2b6);} .swatch[data-theme="sandstone"]{background:linear-gradient(135deg,#f7efe4,#e4d2b6);}
/* Windows 95 swatch */
/* Windows 95 swatch */
.swatch[data-theme="win95"] {
background: linear-gradient(to bottom, #000080 0 30%, #c0c0c0 30% 100%);
border: 2px solid #808080;
border-top-color: #fff; /* top highlight */
border-left-color: #fff; /* left highlight */
border-right-color: #404040;
border-bottom-color: #404040;
border-radius: 0; /* blocky, no rounded corners */
box-shadow: inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 #000;
font-family: "MS Sans Serif", sans-serif;
position: relative;
overflow: hidden;
}
.swatch-name{background:rgba(255,255,255,.8);padding:2px 6px;border-radius:8px;font-size:12px;color:#0f172a;} .swatch-name{background:rgba(255,255,255,.8);padding:2px 6px;border-radius:8px;font-size:12px;color:#0f172a;}
/* Security accent */ /* Security accent */
.cc-sec{position:relative;overflow:hidden;color:#0f172a;} .cc-sec{position:relative;overflow:hidden;color:#0f172a;}
@ -235,19 +229,18 @@
<script> <script>
(function(){ (function(){
// Helpers // Helpers already present in your file (unchanged)
function getCookie(name){ const m=document.cookie.match("(^|;)\\s*"+name+"\\s*=\\s*([^;]+)"); return m?m.pop():""; } function getCookie(name){ const m=document.cookie.match("(^|;)\\s*"+name+"\\s*=\\s*([^;]+)"); return m?m.pop():""; }
function setTheme(name){ function setTheme(name){
var v='{{ APP_VERSION }}';
var link=document.getElementById('theme-css'); var link=document.getElementById('theme-css');
if(link) link.href='{% static "themes/" %}'+name+'.css?v='+v; // << cache-busted preview if(link) link.href='{% static "themes/" %}'+name+'.css';
document.documentElement.setAttribute('data-theme',name); document.documentElement.setAttribute('data-theme',name);
try{ localStorage.setItem('theme',name); }catch(e){} try{ localStorage.setItem('theme',name); }catch(e){}
if(name==='classic'){ document.body.classList.add('themed-bg'); } else { document.body.classList.remove('themed-bg'); } if(name==='classic'){ document.body.classList.add('themed-bg'); } else { document.body.classList.remove('themed-bg'); }
var hidden=document.getElementById('theme-hidden'); if(hidden) hidden.value=name; var hidden=document.getElementById('theme-hidden'); if(hidden) hidden.value=name;
} }
// Clear history // Clear history (unchanged)
(function(){ (function(){
var btn=document.getElementById("clear-history-btn"); var btn=document.getElementById("clear-history-btn");
var toast=document.getElementById("toast-clear-history"); var toast=document.getElementById("toast-clear-history");
@ -263,13 +256,10 @@
}); });
})(); })();
// Save from tiles // Save from tiles (unchanged)
window.saveTheme = function(){ window.saveTheme = function(){ return true; };
// Hidden input already holds current pick; just submit
return true;
};
// Swatch instant preview + set hidden value // Swatch instant preview (unchanged)
document.querySelectorAll('.swatch').forEach(btn=>{ document.querySelectorAll('.swatch').forEach(btn=>{
btn.addEventListener('click',()=>{ btn.addEventListener('click',()=>{
const name=btn.getAttribute('data-theme'); const name=btn.getAttribute('data-theme');
@ -277,7 +267,7 @@
}); });
}); });
// Highlight toggle // Highlight toggle (server-backed, unchanged)
(async function(){ (async function(){
const toggle=document.getElementById("highlightHitsToggle"); const toggle=document.getElementById("highlightHitsToggle");
if(!toggle) return; if(!toggle) return;
@ -295,4 +285,7 @@
})(); })();
})(); })();
</script> </script>
<!-- NEW: client-side swipe preference wiring -->
<script src="{% static 'js/settings-swipe.js' %}"></script>
{% endblock %} {% endblock %}