Illustrations/web/templates/entry_view.html

608 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 %}
{% with s=entry.source %}
{% with sl=s|lower %}
{% if sl|slice:":2" == "wp" or sl|slice:":2" == "ws" or sl|slice:":2" == "yb" or sl|slice:":3" == "mwb" or sl|slice:":1" == "w" or sl|slice:":1" == "g" or sl|slice:":2" == "ap" or sl|slice:":3" == "apf" or sl|slice:":2" == "be" or sl|slice:":2" == "bh" or sl|slice:":2" == "br" or sl|slice:":2" == "bt" or sl|slice:":3" == "btg" or sl|slice:":2" == "cf" or sl|slice:":2" == "cl" or sl|slice:":2" == "ct" or sl|slice:":2" == "dp" or sl|slice:":2" == "fg" or sl|slice:":2" == "fy" or sl|slice:":2" == "gt" or sl|slice:":2" == "hb" or sl|slice:":2" == "im" or sl|slice:":2" == "ip" or sl|slice:":2" == "it" or sl|slice:":2" == "jv" or sl|slice:":2" == "ka" or sl|slice:":2" == "kj" or sl|slice:":2" == "kl" or sl|slice:":2" == "lf" or sl|slice:":3" == "lff" or sl|slice:":2" == "ll" or sl|slice:":2" == "ly" or sl|slice:":2" == "my" or sl|slice:":2" == "od" or sl|slice:":2" == "pe" or sl|slice:":2" == "po" or sl|slice:":2" == "pt" or sl|slice:":2" == "rr" or sl|slice:":2" == "rs" or sl|slice:":2" == "sg" or sl|slice:":2" == "sh" or sl|slice:":2" == "si" or sl|slice:":2" == "td" or sl|slice:":2" == "tp" or sl|slice:":2" == "tr" or sl|slice:":2" == "ts" or sl|slice:":2" == "un" %}
<a class="chip chip-link"
href="https://wol.jw.org/en/wol/l/r1/lp-e?q={{ s|urlencode }}"
target="_blank" rel="noopener noreferrer">{{ s }}</a>
{% else %}
<a class="chip chip-link"
href="https://www.google.com/search?q={{ s|urlencode }}"
target="_blank" rel="noopener noreferrer">{{ s }}</a>
{% endif %}
{% endwith %}
{% endwith %}
{% 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"
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;
}
/* Light gray highlight for search hits */
.mark-hit {
background: #ececec;
border-radius: 3px;
padding: 0 .15em;
}
</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(); // <- may throw NotAllowedError on some iOS/Safari cases
playing = true;
} catch (err) {
// Autoplay/permission style block (Safari/iOS or Chrome heuristics)
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;
}
}
}
// -------- button: toggle behavior --------
ttsBtn.addEventListener('click', async () => {
// Toggle OFF if something is already playing (either engine)
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;
}
});
// Safety: stop audio when navigating away
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);
// Trigger fade in
setTimeout(() => toast.classList.add("show"), 50);
// Fade out & remove
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => document.body.removeChild(toast), 500);
}, duration);
}
</script>
<!-- Highlighter: robust triggers (DOMContentLoaded + pageshow + readyState) -->
<script>
(function(){
// EXACT field -> selector mapping for your template IDs
const fieldToSelector = {
subject: "#subject-list",
illustration: "#illustration-text",
application: "#application-text",
scripture_raw: "#scripture-text",
source: "#source-text",
talk_title: "#talk_title-text",
talk_number: "#talk_title-text",
};
// Kickoff with multiple safety nets so it also runs after bfcache restores
if (document.readyState === "complete" || document.readyState === "interactive") {
run();
} else {
document.addEventListener("DOMContentLoaded", run, { once: true });
}
// pageshow fires on bfcache restores and some soft navigations
window.addEventListener("pageshow", run);
async function run() {
// Respect per-user toggle
let enabled = true;
try {
const res = await fetch("/api/get-prefs/");
const prefs = await res.json();
if (prefs && typeof prefs.highlight_search_hits !== "undefined") {
enabled = !!prefs.highlight_search_hits;
}
} catch (_) {}
if (!enabled) return;
// Read last search (prefer JS-literal fallback, then JSON blob)
let q = (window.__lastSearchQ || "").trim();
let fields = Array.isArray(window.__lastSearchFields) ? window.__lastSearchFields : [];
if ((!q || !fields.length)) {
const dataEl = document.getElementById("last-search-data");
if (dataEl) {
try {
const payload = JSON.parse(dataEl.textContent || "{}");
q = (payload.q || "").trim();
fields = Array.isArray(payload.fields) ? payload.fields : [];
} catch (_) {}
}
}
if (!q || !fields.length) return;
// Tokenize like your search (keep quoted phrases, strip * and ?)
const tokens = tokenize(q).map(t => t.replaceAll("*","").replaceAll("?","")).filter(Boolean);
if (!tokens.length) return;
// Highlight only within fields that were searched
for (const f of fields) {
const sel = fieldToSelector[f];
if (!sel) continue;
const container = document.querySelector(sel);
if (!container) continue;
for (const tok of tokens) highlightAll(container, tok);
}
}
// ---- helpers ----
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 escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlightAll(root, needle) {
if (!needle) return;
const re = new RegExp(escapeRegExp(needle), "gi");
walkTextNodes(root, (textNode) => {
const text = textNode.nodeValue;
if (!re.test(text)) return;
const frag = document.createDocumentFragment();
let lastIndex = 0;
text.replace(re, (m, idx) => {
if (idx > lastIndex) frag.appendChild(document.createTextNode(text.slice(lastIndex, idx)));
const mark = document.createElement("mark");
mark.className = "mark-hit";
mark.textContent = m;
frag.appendChild(mark);
lastIndex = idx + m.length;
return m;
});
if (lastIndex < text.length) frag.appendChild(document.createTextNode(text.slice(lastIndex)));
textNode.parentNode.replaceChild(frag, textNode);
});
}
function walkTextNodes(node, cb) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(cb);
}
})();
</script>
<!-- DEBUG: prove scripts run on load -->
<script>
(function(){
// 1) blatant visual badge
const badge = document.createElement('div');
badge.textContent = 'HL script loaded';
badge.style.cssText = 'position:fixed;bottom:8px;right:8px;background:#111;color:#fff;padding:6px 10px;border-radius:6px;font:12px system-ui;z-index:99999;opacity:.85';
document.body.appendChild(badge);
setTimeout(()=>badge.remove(), 1500);
// 2) console beacons for all relevant lifecycle events
console.log('[HL] inline script evaluated. readyState=', document.readyState);
document.addEventListener('DOMContentLoaded', () => console.log('[HL] DOMContentLoaded'));
window.addEventListener('pageshow', (e) => console.log('[HL] pageshow (bfcache=', e.persisted, ')'));
})();
</script>
{% endblock %}