Integrate swipe and d-pad to transit entry_view #2
@ -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>
|
||||||
@ -38,24 +38,23 @@
|
|||||||
Share
|
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 +91,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 +131,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"
|
||||||
@ -206,22 +204,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 +224,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 +293,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 +301,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 +356,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 +431,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 it’s 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 +464,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 +478,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 %}
|
||||||
Loading…
Reference in New Issue
Block a user