Illustrations/web/templates/entry_view.html

668 lines
21 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 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>
<!-- 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">
{% if subject_list %}
{% for s in subject_list %}
<a
class="chip chip-subject"
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>
<!-- 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 its 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>
{% endblock %}