Illustrations/web/templates/entry_view.html

486 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block body_class %}themed-bg{% endblock %}
{% load static %}
{% block content %}
<div class="result-wrap">
<!-- 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>
<div class="rt-right">
<form id="navPrevForm" 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 id="navNextForm" 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>
<!-- 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/Delete for staff -->
{% if user.is_authenticated and user.is_staff %}
<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 %}
{% 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>
<!-- 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;
}
.chip-subject.chip-hit{
background:#f8e3c5;
border-color:#e0b98e;
color:#111;
}
.mark-hit {
background: #f8e3c5;
border-radius: 3px;
padding: 0 .15em;
}
.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) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
try {
var ta = document.createElement('textarea');
ta.value = text;
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 ===== -->
<script>
(function(){
const ttsBtn = document.getElementById('ttsBtn');
if (!ttsBtn) return;
const TTS_URL = "{{ tts_url|default:'' }}"; // empty for non-staff → browser TTS
let audioEl = null, currentURL = null, playing = false, fetchCtrl = null;
function ensureAudio(){ if (!audioEl){ audioEl = new Audio(); audioEl.setAttribute('playsinline',''); audioEl.preload='auto';
audioEl.addEventListener('ended', ()=>{ playing=false; cleanupURL(); }); } 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; }
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 logic (unchanged) -->
<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 w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
while(w.nextNode()) nodes.push(w.currentNode);
const wordRe = makeWordRegex();
for (const textNode of nodes) {
const text = textNode.nodeValue; if (!text || !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, 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');
} else {
link.title = (link.title ? link.title + ' • ' : '') + 'Search on Google';
link.classList.add('scripture-pill-invalid');
}
})();
</script>
<!-- Scripture pill validation -->
<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 %}
{% block extra_body %}
<!-- Swipe/keyboard navigation (mobile + desktop), client-side pref aware -->
<script src="{% static 'js/swipe-nav.js' %}"></script>
{% endblock %}