Update web/templates/entry_view.html
This commit is contained in:
parent
3a3d3efd52
commit
84c6037b30
@ -1,37 +1,671 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body_class %}themed-bg{% endblock %}
|
||||
{% load static %}
|
||||
|
||||
{% block body_class %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="result-wrap">
|
||||
|
||||
<div class="entry-header">
|
||||
<h1 class="subject-title">{{ entry.title }}</h1>
|
||||
<p class="muted">{{ entry.subtitle }}</p>
|
||||
<!-- Top bar: back + counter + clear Prev/Next -->
|
||||
<div class="result-toolbar">
|
||||
<div class="rt-left">
|
||||
<a class="btn btn-secondary" href="{% url 'search' %}">← Back to Search</a>
|
||||
{% if count %}
|
||||
<span class="rt-count">{{ position }} of {{ count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Your existing entry content goes here… #}
|
||||
|
||||
<div class="nav-row" style="display:flex;gap:10px;align-items:center;margin-top:16px;">
|
||||
<form id="navPrevForm" method="post" action="{% url 'nav_prev' %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="q" value="{{ request.GET.q|default:'' }}">
|
||||
<input type="hidden" name="i" value="{{ request.GET.i|default:'0' }}">
|
||||
<button class="btn btn-lg">‹ Prev</button>
|
||||
<div class="rt-right">
|
||||
<form method="get" action="{% url 'nav_prev' %}" class="inline">
|
||||
<!-- send current zero-based index (position-1); view will subtract 1 -->
|
||||
<input type="hidden" name="i" value="{{ position|add:'-1' }}">
|
||||
<button class="btn btn-lg" {% if position <= 1 %}disabled{% endif %}>‹ Prev</button>
|
||||
</form>
|
||||
<form method="get" action="{% url 'nav_next' %}" class="inline">
|
||||
<!-- send current zero-based index (position-1); view will 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>
|
||||
</form>
|
||||
|
||||
<form id="navNextForm" method="post" action="{% url 'nav_next' %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="q" value="{{ request.GET.q|default:'' }}">
|
||||
<input type="hidden" name="i" value="{{ request.GET.i|default:'0' }}">
|
||||
<button class="btn btn-lg">Next ›</button>
|
||||
</form>
|
||||
<!-- Share button (copies Illustration + two spaces + Application) -->
|
||||
<button id="share-btn" class="btn btn-lg btn-primary" type="button" title="Copy to clipboard" style="margin-left:6px;">
|
||||
<span style="display:flex;align-items:center;gap:6px;">
|
||||
<!-- iOS-like share icon (SVG) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5V9a.5.5 0 0 1-1 0V1.5A.5.5 0 0 1 8 1z"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
Share
|
||||
</span>
|
||||
</button>
|
||||
<!-- Share button ... -->
|
||||
|
||||
<button class="btn btn-secondary" id="ttsBtn"
|
||||
title="Read aloud"
|
||||
aria-label="Read illustration out loud"
|
||||
{% if tts_url %} data-tts-url="{{ tts_url }}"{% endif %}
|
||||
data-illustration="{{ entry.illustration|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;">
|
||||
<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="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>
|
||||
</button>
|
||||
|
||||
<!-- Edit button ... -->
|
||||
{% 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-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main card -->
|
||||
<div class="result-card">
|
||||
<!-- SUBJECT pills (WOL-linked) -->
|
||||
<div class="subject-pills" id="subject-list" data-search-url="{% url 'search' %}">
|
||||
{% if subject_list %}
|
||||
{% for s in subject_list %}
|
||||
<a
|
||||
class="chip chip-subject subject-pill"
|
||||
href="https://wol.jw.org/en/wol/s/r1/lp-e?q={{ s }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
title="Search WOL for {{ s }}"
|
||||
>{{ s }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="chip chip-muted">(no subject)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ILLUSTRATION -->
|
||||
<div class="section">
|
||||
<div class="section-label">Illustration</div>
|
||||
<div class="section-body lead-text" id="illustration-text">
|
||||
{{ entry.illustration|linebreaksbr|default:"—" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APPLICATION -->
|
||||
<div class="section">
|
||||
<div class="section-label">Application</div>
|
||||
<div class="section-body lead-text" id="application-text">
|
||||
{{ entry.application|linebreaksbr|default:"—" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta (smaller) -->
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Source</div>
|
||||
<div class="meta-value" id="source-text">
|
||||
{% if entry.source %}
|
||||
<a class="chip chip-link js-source-link"
|
||||
data-source="{{ entry.source }}"
|
||||
href="https://www.google.com/search?q={{ entry.source|urlencode }}"
|
||||
target="_blank" rel="noopener noreferrer">{{ entry.source }}</a>
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Scripture</div>
|
||||
<div class="meta-value" id="scripture-text">
|
||||
{% if scripture_list %}
|
||||
{% for sc in scripture_list %}
|
||||
<a class="chip chip-link scripture-pill"
|
||||
href="https://wol.jw.org/en/wol/l/r1/lp-e?q={{ sc|urlencode }}"
|
||||
target="_blank" rel="noopener noreferrer">{{ sc }}</a>
|
||||
{% endfor %}
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Code</div>
|
||||
<div class="meta-value">{{ entry.entry_code|default:"—" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Talk</div>
|
||||
<div class="meta-value" id="talk_title-text">
|
||||
{% if entry.talk_title %}{{ entry.talk_title }}{% else %}—{% endif %}
|
||||
{# Talk Number pill (staff-only link) #}
|
||||
{% if entry.talk_number %}
|
||||
{% if user.is_authenticated and user.is_staff and talk_pdf_url %}
|
||||
<a href="{{ talk_pdf_url }}" target="_blank" rel="noopener"
|
||||
class="chip chip-link" title="Open talk PDF">
|
||||
<span class="muted">#:</span>
|
||||
<strong>{{ entry.talk_number }}</strong>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="chip">
|
||||
<span class="muted">#:</span>
|
||||
<strong>{{ entry.talk_number }}</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Dates</div>
|
||||
<div class="meta-value small">
|
||||
{% if entry.date_added %}Added: {{ entry.date_added }}{% else %}Added: —{% endif %}
|
||||
{% if entry.date_edited %} • Edited: {{ entry.date_edited }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script src="{% static 'js/swipe-nav.js' %}"></script>
|
||||
<!-- Toast for copy confirmation -->
|
||||
<div id="copy-toast"
|
||||
style="position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
|
||||
background:#333;color:#fff;padding:10px 16px;border-radius:6px;
|
||||
font-size:14px;display:none;z-index:9999;">
|
||||
The Illustration was copied to the clipboard
|
||||
</div>
|
||||
|
||||
<!-- Last search data for highlighter -->
|
||||
<script id="last-search-data" type="application/json">
|
||||
{
|
||||
"q": "{{ last_search_q|escapejs }}",
|
||||
"fields": {{ last_search_fields|default:"[]"|safe }}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// JS-literal fallback (works even if Django prints single quotes)
|
||||
window.__lastSearchQ = "{{ last_search_q|escapejs }}";
|
||||
window.__lastSearchFields = {{ last_search_fields|default:"[]"|safe }};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subject-pills{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
margin:0 0 10px;
|
||||
}
|
||||
.chip-subject{
|
||||
font-weight:700;
|
||||
font-size:16px;
|
||||
padding:8px 14px;
|
||||
border-radius:999px;
|
||||
background:#eef5fc;
|
||||
border:1px solid #d7e6f7;
|
||||
color:#0f172a;
|
||||
text-decoration:none;
|
||||
text-transform: capitalize; /* Capitalize each word */
|
||||
}
|
||||
.chip-subject:hover{
|
||||
background:#e2effc;
|
||||
border-color:#c9def5;
|
||||
}
|
||||
|
||||
/* Subject chip gets a hit → color the whole chip */
|
||||
.chip-subject.chip-hit{
|
||||
background:#f8e3c5; /* your chosen highlight color */
|
||||
border-color:#e0b98e;
|
||||
color:#111;
|
||||
}
|
||||
|
||||
/* Light gray (peach) highlight for inline text hits */
|
||||
.mark-hit {
|
||||
background: #f8e3c5;
|
||||
border-radius: 3px;
|
||||
padding: 0 .15em;
|
||||
}
|
||||
|
||||
/* Subtle invalid style for individual Scripture pills */
|
||||
.chip-link.scripture-pill-invalid {
|
||||
background-color: hsl(0 80% 94% / 0.75);
|
||||
border-color: #efc1c1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Robust copy to clipboard that works on HTTP and HTTPS
|
||||
(function () {
|
||||
function copyText(text) {
|
||||
// Try modern API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// Fallback: temporary textarea
|
||||
return new Promise(function (resolve, reject) {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
// Avoid scrolling to bottom
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '-1000px';
|
||||
ta.style.left = '-1000px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
var ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
ok ? resolve() : reject(new Error('execCommand failed'));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showToast() {
|
||||
var toast = document.getElementById('copy-toast');
|
||||
if (!toast) return;
|
||||
toast.style.display = 'block';
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(function(){ toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
var btn = document.getElementById('share-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var ill = (document.getElementById('illustration-text')?.innerText || '').trim();
|
||||
var app = (document.getElementById('application-text')?.innerText || '').trim();
|
||||
var text = ill + ' ' + app; // two spaces
|
||||
copyText(text).then(showToast).catch(function (err) {
|
||||
alert('Failed to copy: ' + err);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
{% if user.is_authenticated %}
|
||||
const entryId = {{ entry.id }};
|
||||
let timer = null;
|
||||
|
||||
function visible(){ return document.visibilityState === 'visible'; }
|
||||
function start(){
|
||||
if (timer || !visible()) return;
|
||||
timer = setTimeout(()=>{
|
||||
const fd = new FormData();
|
||||
fetch("{% url 'api_log_view' 0 %}".replace("0", entryId), {
|
||||
method:'POST', body: fd,
|
||||
headers: {'X-CSRFToken': (document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/)||[]).pop() || '' }
|
||||
}).catch(()=>{});
|
||||
}, 10000); // 10 seconds
|
||||
}
|
||||
function stop(){ if (timer){ clearTimeout(timer); timer = null; } }
|
||||
|
||||
document.addEventListener('visibilitychange', ()=> visible() ? start() : stop());
|
||||
window.addEventListener('pagehide', stop);
|
||||
start();
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ===== TTS with play/stop toggle (ONLY this block changed) ===== -->
|
||||
<script>
|
||||
(function(){
|
||||
const ttsBtn = document.getElementById('ttsBtn');
|
||||
if (!ttsBtn) return;
|
||||
|
||||
const TTS_URL = "{{ tts_url|default:'' }}"; // empty for non-staff → browser TTS
|
||||
|
||||
// -------- persistent audio + helpers --------
|
||||
let audioEl = null;
|
||||
let currentURL = null; // objectURL for OpenAI response
|
||||
let playing = false;
|
||||
let fetchCtrl = null;
|
||||
|
||||
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(){
|
||||
const ill = (document.getElementById('illustration-text')?.innerText || '').trim();
|
||||
const app = (document.getElementById('application-text')?.innerText || '').trim();
|
||||
const illP = ill && /[.!?…]$/.test(ill) ? ill : (ill ? ill + '.' : '');
|
||||
return [illP, app].filter(Boolean).join(' ') || 'No text available for this illustration.';
|
||||
}
|
||||
|
||||
function speakBrowserTTS(){
|
||||
const text = buildCombinedText();
|
||||
if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) {
|
||||
throw new Error('Browser TTS not supported.');
|
||||
}
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 1.0; u.pitch = 1.0; u.volume = 1.0;
|
||||
speechSynthesis.speak(u);
|
||||
playing = true;
|
||||
}
|
||||
|
||||
async function playOpenAITTS(){
|
||||
stopAll();
|
||||
fetchCtrl = new AbortController();
|
||||
|
||||
const r = await fetch(TTS_URL, { credentials:'same-origin', cache:'no-store', signal: fetchCtrl.signal });
|
||||
if (!r.ok) {
|
||||
const preview = await r.text().catch(()=> String(r.status));
|
||||
throw new Error(`HTTP ${r.status}: ${preview.slice(0,200)}`);
|
||||
}
|
||||
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.startsWith('audio/')) {
|
||||
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 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.");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ttsBtn.addEventListener('click', async () => {
|
||||
if (playing) {
|
||||
try {
|
||||
stopAll();
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
} finally {
|
||||
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);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
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>
|
||||
|
||||
<script>
|
||||
function showToast(message, duration = 3000) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add("show"), 50);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => document.body.removeChild(toast), 500);
|
||||
}, duration);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Highlighter: apply to ALL fields; for Subjects, color the whole chip; highlight WHOLE WORDS -->
|
||||
<script>
|
||||
(function(){
|
||||
const ALL_SELECTORS = [
|
||||
"#subject-list",
|
||||
"#illustration-text",
|
||||
"#application-text",
|
||||
"#scripture-text",
|
||||
"#source-text",
|
||||
"#talk_title-text"
|
||||
];
|
||||
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") run();
|
||||
else document.addEventListener("DOMContentLoaded", run, { once: true });
|
||||
window.addEventListener("pageshow", run);
|
||||
|
||||
let ran = false;
|
||||
async function run(){
|
||||
if (ran) return;
|
||||
|
||||
let enabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/get-prefs/", { cache: "no-store", credentials: "same-origin" });
|
||||
if (res.ok) {
|
||||
const prefs = await res.json();
|
||||
if (typeof prefs.highlight_search_hits !== "undefined") enabled = !!prefs.highlight_search_hits;
|
||||
}
|
||||
} catch(_) {}
|
||||
if (!enabled) { ran = true; return; }
|
||||
|
||||
let q = (window.__lastSearchQ || "").trim();
|
||||
if (!q) {
|
||||
const dataEl = document.getElementById("last-search-data");
|
||||
if (dataEl) {
|
||||
try { q = (JSON.parse(dataEl.textContent||"{}").q || "").trim(); } catch(_){}
|
||||
}
|
||||
}
|
||||
if (!q) q = (localStorage.getItem("lastSearchQ") || "").trim();
|
||||
if (!q) { ran = true; return; }
|
||||
|
||||
const tokens = tokenize(q).map(t => t.replaceAll("*","").replaceAll("?","")).filter(Boolean);
|
||||
if (!tokens.length) { ran = true; return; }
|
||||
|
||||
for (const sel of ALL_SELECTORS) {
|
||||
const container = document.querySelector(sel);
|
||||
if (!container) continue;
|
||||
for (const tok of tokens) highlightAll(container, tok);
|
||||
}
|
||||
|
||||
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; }
|
||||
} 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;
|
||||
const needleLower = needle.toLowerCase();
|
||||
|
||||
if (root.id === "subject-list") {
|
||||
root.querySelectorAll(".chip-subject, .chip-muted").forEach(chip => {
|
||||
if ((chip.textContent || "").toLowerCase().includes(needleLower)) {
|
||||
chip.classList.add("chip-hit");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
|
||||
const wordRe = makeWordRegex();
|
||||
|
||||
for (const textNode of nodes) {
|
||||
const text = textNode.nodeValue;
|
||||
if (!text) continue;
|
||||
if (!text.toLowerCase().includes(needleLower)) continue;
|
||||
|
||||
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];
|
||||
if (word.toLowerCase().includes(needleLower)) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "mark-hit";
|
||||
mark.textContent = word;
|
||||
frag.appendChild(mark);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Include shared validators -->
|
||||
<script src="{% static 'js/scripture-validator.v1.js' %}"></script>
|
||||
<script src="{% static 'js/source-validator.v1.js' %}"></script>
|
||||
|
||||
<!-- Upgrade Source link to WOL if valid; otherwise keep Google and tint red -->
|
||||
<script>
|
||||
(function () {
|
||||
const link = document.querySelector('#source-text .js-source-link');
|
||||
if (!link) return;
|
||||
const srcText = link.getAttribute('data-source') || (link.textContent || '').trim();
|
||||
|
||||
if (SourceValidator.isWOLSource(srcText)) {
|
||||
link.href = SourceValidator.buildWOLSearchURL(srcText);
|
||||
link.title = (link.title ? link.title + ' • ' : '') + 'Open in WOL';
|
||||
link.classList.remove('scripture-pill-invalid'); // ensure it’s not red if previously set
|
||||
} else {
|
||||
// Keep Google link, but flag visually like invalid scripture
|
||||
link.title = (link.title ? link.title + ' • ' : '') + 'Search on Google';
|
||||
link.classList.add('scripture-pill-invalid');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Scripture pill validation you already use -->
|
||||
<script>
|
||||
(function () {
|
||||
function validatePills() {
|
||||
const container = document.getElementById('scripture-text');
|
||||
if (!container) return;
|
||||
const pills = container.querySelectorAll('a.chip, a.chip-link');
|
||||
|
||||
pills.forEach(pill => {
|
||||
const txt = (pill.textContent || '').trim();
|
||||
if (!txt) return;
|
||||
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'text';
|
||||
tmp.value = txt;
|
||||
ScriptureValidator.attach(tmp);
|
||||
|
||||
const isValid = tmp.classList.contains('scripture-valid');
|
||||
if (!isValid) {
|
||||
pill.classList.add('scripture-pill-invalid');
|
||||
pill.title = (pill.title ? pill.title + ' • ' : '') + 'Unrecognized scripture format';
|
||||
}
|
||||
tmp.remove();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', validatePills, { once: true });
|
||||
} else {
|
||||
validatePills();
|
||||
}
|
||||
window.addEventListener('pageshow', validatePills);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- NEW: Scripture pill popup + NWT Study Bible deep-linking -->
|
||||
<script src="{% static 'js/scripture-actions.v1.js' %}"></script>
|
||||
|
||||
<!-- NEW: Subject pill popup + Insight/Wiki/Google/DB search -->
|
||||
<script src="{% static 'js/subject-actions.v1.js' %}"></script>
|
||||
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user