William Mattingly
Add scripture detector app
a9a9428
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scripture Detector — {{ source.name }}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
.header { position: sticky; top: 0; }
.action-bar {
display: flex;
gap: 10px;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border-light);
align-items: center;
flex-wrap: wrap;
}
.select-styled {
padding: 7px 12px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border);
background: var(--surface);
font-size: .82rem;
color: var(--text);
font-family: inherit;
transition: var(--transition);
}
.select-styled:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-subtle);
}
.tabs {
display: flex;
border-bottom: 2px solid var(--border-light);
padding: 0 24px;
background: var(--surface);
}
.tab {
padding: 12px 22px;
font-size: .84rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: var(--transition);
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.main { display: grid; grid-template-columns: 1fr 400px; height: calc(100vh - 160px); }
@media (max-width: 900px) { .main { grid-template-columns: 1fr; } }
.text-panel {
padding: 32px 36px;
overflow-y: auto;
line-height: 1.9;
font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, Georgia, serif;
font-size: 1.02rem;
white-space: pre-wrap;
word-wrap: break-word;
background: var(--surface);
}
.seg-full { background: var(--full-bg); border-bottom: 2px solid var(--full);
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
.seg-partial { background: var(--partial-bg); border-bottom: 2px solid var(--partial);
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
.seg-paraphrase { background: var(--paraphrase-bg); border-bottom: 2px solid var(--paraphrase);
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
.seg-allusion { background: var(--allusion-bg); border-bottom: 2px solid var(--allusion);
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
.seg-multi { background: rgba(99,102,241,.12); border-bottom: 2px solid var(--primary);
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
[class^="seg-"]:hover { filter: brightness(.92); }
.seg-active { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 3px; }
.ann-panel {
background: var(--bg);
border-left: 1px solid var(--border-light);
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ann-panel h2 {
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted);
margin: 8px 0 4px;
font-weight: 700;
}
.ann-card {
background: var(--surface);
border-radius: var(--radius);
border-left: 4px solid var(--border);
padding: 14px 16px;
font-size: .83rem;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: all .15s;
position: relative;
border: 1px solid var(--border-light);
}
.ann-card:hover { box-shadow: var(--shadow-md); }
.ann-card.active { box-shadow: 0 0 0 2px var(--primary); }
.ann-full { border-left: 4px solid var(--full) !important; }
.ann-partial { border-left: 4px solid var(--partial) !important; }
.ann-paraphrase { border-left: 4px solid var(--paraphrase) !important; }
.ann-allusion { border-left: 4px solid var(--allusion) !important; }
.ann-refs { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
.ref-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 5px;
font-size: .72rem;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-light);
}
.ann-quote {
color: var(--muted);
font-style: italic;
margin-bottom: 8px;
max-height: 3.6em;
overflow: hidden;
line-height: 1.2em;
font-size: .8rem;
}
.ann-verse { border-top: 1px solid var(--border-light); padding-top: 8px; margin-top: 6px; font-size: .78rem; }
.ann-verse strong {
color: var(--text-secondary);
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
font-size: .72rem;
}
.ann-verse .vtext { color: var(--muted); }
.ann-actions { display: flex; gap: 6px; margin-top: 10px; }
/* Distribution tab */
.dist-content { max-width: 1000px; margin: 0 auto; padding: 28px; }
.dist-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 800px) { .dist-grid { grid-template-columns: 1fr; } }
.dist-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
padding: 24px;
}
.dist-card h3 {
font-size: .8rem;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
}
.chart-wrap { position: relative; width: 100%; height: 300px; }
/* Selection toolbar */
.sel-toolbar {
position: fixed;
z-index: 200;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-xl);
padding: 8px;
display: none;
gap: 6px;
border: 1px solid var(--border-light);
}
.sel-toolbar.visible { display: flex; }
/* Reference tags */
.ref-tags { display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px; padding: 6px 0; }
.ref-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--primary-light);
color: #3730a3;
border-radius: 6px;
font-size: .78rem;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
}
.ref-tag button {
background: none;
border: none;
cursor: pointer;
color: #6366f1;
font-size: .9rem;
line-height: 1;
padding: 0 2px;
}
.ref-tag button:hover { color: var(--red); }
/* Reference picker */
.ref-picker {
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-top: 10px;
background: var(--bg);
}
.ref-picker-row { display: flex; gap: 8px; margin-bottom: 10px; }
.ref-picker-row select {
flex: 1;
padding: 8px 12px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border);
font-size: .84rem;
background: var(--surface);
font-family: inherit;
}
.ref-picker-row select:disabled { opacity: .4; }
.ref-verses {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
background: var(--surface);
}
.ref-verse-item {
display: flex;
gap: 8px;
padding: 8px 12px;
font-size: .82rem;
cursor: pointer;
transition: background .1s;
align-items: flex-start;
border-bottom: 1px solid var(--border-light);
}
.ref-verse-item:last-child { border-bottom: none; }
.ref-verse-item:hover { background: var(--primary-subtle); }
.ref-verse-item.added { background: rgba(22,163,74,.06); }
.ref-verse-num {
font-weight: 700;
color: var(--primary);
min-width: 24px;
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
font-size: .78rem;
padding-top: 1px;
}
.ref-verse-text { color: var(--muted); flex: 1; line-height: 1.4; font-size: .8rem; }
.ref-verse-add {
font-size: .7rem;
font-weight: 700;
color: var(--green);
white-space: nowrap;
padding-top: 1px;
}
.ref-verse-item.added .ref-verse-add { color: var(--muted); }
.ref-no-verses { padding: 20px; text-align: center; color: var(--muted); font-size: .84rem; }
.legend-bar {
display: flex;
gap: 16px;
font-size: .74rem;
color: var(--muted);
align-items: center;
flex-wrap: wrap;
}
.legend-item { display: flex; align-items: center; gap: 5px; }
.legend-swatch { width: 22px; height: 8px; border-radius: 4px; }
</style>
</head>
<body>
<div class="header">
<a href="/" class="header-brand">
<img src="/static/logo.svg" alt="Logo" class="header-logo">
<div>
<div class="header-title">Scripture Detector</div>
<div class="header-subtitle">{{ source.name }}</div>
</div>
</a>
<nav>
<a href="/">Sources</a>
<a href="/dashboard">Dashboard</a>
<a href="/about">About</a>
<a href="/settings">Settings</a>
</nav>
</div>
<div class="action-bar">
<button class="btn btn-primary" id="process-btn" onclick="processSource()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Process with AI
</button>
<a class="btn btn-ghost" id="export-tei-btn"
href="/api/sources/{{ source.id }}/export/tei" download
title="Download this source as TEI XML">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export TEI
</a>
<select class="select-styled" id="model-select">
{% for m in models %}
<option value="{{ m.id }}" {{ 'selected' if m.id == current_model else '' }}>{{ m.name }}</option>
{% endfor %}
</select>
<button class="btn btn-ghost" id="add-quote-btn" onclick="openAddModal()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Quote
</button>
<div style="margin-left:auto">
<div class="legend-bar">
<div class="legend-item"><div class="legend-swatch" style="background:var(--full-bg);border-bottom:2px solid var(--full)"></div>Full</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--partial-bg);border-bottom:2px solid var(--partial)"></div>Partial</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--paraphrase-bg);border-bottom:2px solid var(--paraphrase)"></div>Paraphrase</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--allusion-bg);border-bottom:2px solid var(--allusion)"></div>Allusion</div>
</div>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="text">Text</div>
<div class="tab" data-tab="distribution">Distribution</div>
</div>
<div class="tab-content active" id="tab-text">
<div class="main">
<div class="text-panel" id="text-panel"><div class="loading"><div class="spinner"></div>Loading...</div></div>
<div class="ann-panel" id="ann-panel"></div>
</div>
</div>
<div class="tab-content" id="tab-distribution">
<div class="dist-content">
<div class="dist-grid">
<div class="dist-card">
<h3>Bible Books Referenced</h3>
<div class="chart-wrap"><canvas id="dist-book-chart"></canvas></div>
</div>
<div class="dist-card">
<h3>Quote Type Distribution</h3>
<div class="chart-wrap"><canvas id="dist-type-chart"></canvas></div>
</div>
</div>
</div>
</div>
<div class="sel-toolbar" id="sel-toolbar">
<button class="btn btn-primary btn-sm" onclick="analyzeSelection()">Analyze Selection</button>
<button class="btn btn-ghost btn-sm" onclick="addFromSelection()">Add as Quote</button>
</div>
<div class="modal-overlay" id="quote-modal">
<div class="modal">
<h2 id="modal-title">Add Quote</h2>
<input type="hidden" id="qm-id">
<label>Quote Text</label>
<textarea id="qm-text" rows="2" style="min-height:60px"></textarea>
<label>Quote Type</label>
<select id="qm-type">
<option value="full">Full</option>
<option value="partial">Partial</option>
<option value="paraphrase">Paraphrase</option>
<option value="allusion" selected>Allusion</option>
</select>
<label>References</label>
<div class="ref-tags" id="qm-ref-tags"></div>
<div class="ref-picker">
<div class="ref-picker-row">
<select id="ref-book" onchange="onBookChange()">
<option value="">Select Book...</option>
</select>
<select id="ref-chapter" disabled onchange="onChapterChange()">
<option value="">Ch.</option>
</select>
</div>
<div id="ref-verses-wrap" style="display:none">
<div class="ref-verses" id="ref-verses"></div>
</div>
</div>
<input type="hidden" id="qm-start">
<input type="hidden" id="qm-end">
<div class="actions">
<button class="btn btn-ghost" onclick="closeQuoteModal()">Cancel</button>
<button class="btn btn-primary" id="qm-save" onclick="saveQuote()">Save</button>
</div>
</div>
</div>
<script>
const SOURCE_ID = {{ source.id }};
const textPanel = document.getElementById('text-panel');
const annPanel = document.getElementById('ann-panel');
const selToolbar = document.getElementById('sel-toolbar');
const quoteModal = document.getElementById('quote-modal');
let DATA = null;
let selRange = null;
let distChartsRendered = false;
let modalRefs = [];
let bibleBooks = null;
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function showToast(msg, ok) {
const el = document.createElement('div');
el.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 400); }, 3500);
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'distribution' && !distChartsRendered) loadDistribution();
});
});
document.getElementById('model-select').addEventListener('change', e => {
fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: e.target.value}),
});
});
function loadSource() {
textPanel.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
annPanel.innerHTML = '';
fetch(`/api/sources/${SOURCE_ID}`)
.then(r => r.json())
.then(data => { DATA = data; renderText(); });
}
function renderText() {
if (!DATA) return;
const {segments, annotations} = DATA;
let html = '';
segments.forEach((seg, i) => {
if (seg.annotation_ids.length === 0) {
html += `<span data-start="${seg.start}" data-end="${seg.end}">${esc(seg.text)}</span>`;
} else {
const types = new Set(seg.annotation_ids.map(id => annotations[id]?.quote_type));
let cls = 'seg-multi';
if (types.size === 1) cls = `seg-${[...types][0]}`;
const ids = seg.annotation_ids.join(',');
html += `<span class="${cls}" data-ann="${ids}" data-start="${seg.start}" data-end="${seg.end}">${esc(seg.text)}</span>`;
}
});
textPanel.innerHTML = html;
let annHtml = '<h2>Annotations (' + annotations.length + ')</h2>';
if (annotations.length === 0) {
annHtml += '<p style="color:var(--muted);font-size:.84rem;padding:16px 0">No quotes detected yet. Click "Process with AI" to analyze this text.</p>';
}
annotations.forEach((a, i) => {
const qt = a.quote_type || 'allusion';
const snippet = (a.quote_text || '').slice(0, 140);
let versesHtml = '';
(a.verses || []).forEach(v => {
const vt = v.text ? esc(v.text) : '<span style="color:var(--red);font-style:italic">not found</span>';
versesHtml += `<div class="ann-verse"><strong>${esc(v.ref)}</strong> <span class="vtext">${vt}</span></div>`;
});
annHtml += `
<div class="ann-card ann-${qt}" data-idx="${i}" data-id="${a.id}">
<div class="ann-refs">
<span class="type-badge type-${qt}">${qt}</span>
${(a.refs||[]).map(r => `<span class="ref-badge">${esc(r)}</span>`).join('')}
</div>
<div class="ann-quote">"${esc(snippet)}${snippet.length < (a.quote_text||'').length ? '...' : ''}"</div>
${versesHtml}
<div class="ann-actions">
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editQuote(${i})">Edit</button>
<button class="btn btn-ghost btn-sm" style="color:var(--red)" onclick="event.stopPropagation();deleteQuote(${a.id})">Delete</button>
</div>
</div>`;
});
annPanel.innerHTML = annHtml;
bindTextEvents();
}
function bindTextEvents() {
textPanel.querySelectorAll('[data-ann]').forEach(span => {
span.addEventListener('click', () => {
clearActive();
span.classList.add('seg-active');
span.dataset.ann.split(',').forEach(id => {
const card = annPanel.querySelector(`.ann-card[data-idx="${id}"]`);
if (card) { card.classList.add('active'); card.scrollIntoView({behavior:'smooth',block:'center'}); }
});
});
});
annPanel.querySelectorAll('.ann-card').forEach(card => {
card.addEventListener('click', () => {
clearActive();
card.classList.add('active');
const idx = card.dataset.idx;
textPanel.querySelectorAll('[data-ann]').forEach(span => {
if (span.dataset.ann.split(',').includes(idx)) {
span.classList.add('seg-active');
span.scrollIntoView({behavior:'smooth',block:'center'});
}
});
});
});
}
function clearActive() {
document.querySelectorAll('.seg-active').forEach(e => e.classList.remove('seg-active'));
document.querySelectorAll('.ann-card.active').forEach(e => e.classList.remove('active'));
}
function getCharPos(node, offset) {
let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
while (el && !el.dataset.start) el = el.parentElement;
if (!el) return null;
return parseInt(el.dataset.start) + Math.min(offset, (el.textContent || '').length);
}
textPanel.addEventListener('mouseup', () => {
setTimeout(() => {
const sel = window.getSelection();
if (!sel.rangeCount || sel.isCollapsed || sel.toString().trim().length < 3) {
selToolbar.classList.remove('visible'); selRange = null; return;
}
const range = sel.getRangeAt(0);
const s = getCharPos(range.startContainer, range.startOffset);
const e = getCharPos(range.endContainer, range.endOffset);
if (s === null || e === null || e <= s) {
selToolbar.classList.remove('visible'); selRange = null; return;
}
selRange = {start: Math.min(s,e), end: Math.max(s,e), text: sel.toString()};
const rect = range.getBoundingClientRect();
selToolbar.style.top = (rect.top - 44 + window.scrollY) + 'px';
selToolbar.style.left = (rect.left + rect.width/2 - 100) + 'px';
selToolbar.classList.add('visible');
}, 10);
});
document.addEventListener('mousedown', e => {
if (!selToolbar.contains(e.target)) selToolbar.classList.remove('visible');
});
const PROCESS_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>';
function processSource() {
if (DATA && DATA.annotations && DATA.annotations.length > 0) {
const n = DATA.annotations.length;
if (!confirm(
`This source already has ${n} annotation${n !== 1 ? 's' : ''}.\n\n` +
`Processing with AI will delete all existing annotations and replace them with new results.\n\n` +
`This cannot be undone. Continue?`
)) return;
}
const btn = document.getElementById('process-btn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;margin:0"></div> Processing...';
fetch(`/api/sources/${SOURCE_ID}/process`, {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.error) { showToast(data.error, false); return; }
showToast(`Found ${data.count} quotes`, true);
distChartsRendered = false;
loadSource();
})
.catch(e => showToast('Error: ' + e.message, false))
.finally(() => { btn.disabled = false; btn.innerHTML = PROCESS_ICON + ' Process with AI'; });
}
function analyzeSelection() {
if (!selRange) return;
if (DATA && DATA.annotations && DATA.annotations.length > 0) {
const n = DATA.annotations.length;
if (!confirm(
`This source already has ${n} annotation${n !== 1 ? 's' : ''}.\n\n` +
`Analyzing this selection may overwrite existing annotations that overlap the selected passage.\n\n` +
`Continue?`
)) return;
}
selToolbar.classList.remove('visible');
const btn = document.getElementById('process-btn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;margin:0"></div> Analyzing selection...';
fetch(`/api/sources/${SOURCE_ID}/process-selection`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({start: selRange.start, end: selRange.end}),
})
.then(r => r.json())
.then(data => {
if (data.error) { showToast(data.error, false); return; }
showToast(`Found ${data.count} quotes in selection`, true);
distChartsRendered = false;
loadSource();
})
.catch(e => showToast('Error: ' + e.message, false))
.finally(() => { btn.disabled = false; btn.innerHTML = PROCESS_ICON + ' Process with AI'; selRange = null; });
}
function loadBibleBooks() {
if (bibleBooks) return Promise.resolve();
return fetch('/api/bible/books').then(r => r.json()).then(books => {
bibleBooks = books;
const sel = document.getElementById('ref-book');
sel.innerHTML = '<option value="">Select Book...</option>';
books.forEach(b => {
const opt = document.createElement('option');
opt.value = b.code;
opt.textContent = `${b.name} (${b.code})`;
sel.appendChild(opt);
});
});
}
function onBookChange() {
const book = document.getElementById('ref-book').value;
const chSel = document.getElementById('ref-chapter');
const versesWrap = document.getElementById('ref-verses-wrap');
chSel.innerHTML = '<option value="">Ch.</option>';
chSel.disabled = true;
versesWrap.style.display = 'none';
if (!book) return;
fetch(`/api/bible/${book}/chapters`).then(r => r.json()).then(chapters => {
chapters.forEach(ch => {
const opt = document.createElement('option');
opt.value = ch;
opt.textContent = ch;
chSel.appendChild(opt);
});
chSel.disabled = false;
});
}
function onChapterChange() {
const book = document.getElementById('ref-book').value;
const chapter = document.getElementById('ref-chapter').value;
const versesWrap = document.getElementById('ref-verses-wrap');
const versesList = document.getElementById('ref-verses');
versesWrap.style.display = 'none';
if (!book || !chapter) return;
fetch(`/api/bible/${book}/${chapter}/verses`).then(r => r.json()).then(verses => {
if (verses.length === 0) {
versesList.innerHTML = '<div class="ref-no-verses">No verses found</div>';
} else {
versesList.innerHTML = verses.map(v => {
const ref = `${book}_${chapter}:${v.verse}`;
const isAdded = modalRefs.includes(ref);
return `<div class="ref-verse-item${isAdded ? ' added' : ''}" data-ref="${esc(ref)}" onclick="toggleVerse(this,'${esc(ref)}')">
<div class="ref-verse-num">${v.verse}</div>
<div class="ref-verse-text">${esc(v.text)}</div>
<div class="ref-verse-add">${isAdded ? 'Added' : '+ Add'}</div>
</div>`;
}).join('');
}
versesWrap.style.display = 'block';
});
}
function toggleVerse(el, ref) {
const idx = modalRefs.indexOf(ref);
if (idx >= 0) {
modalRefs.splice(idx, 1);
el.classList.remove('added');
el.querySelector('.ref-verse-add').textContent = '+ Add';
} else {
modalRefs.push(ref);
el.classList.add('added');
el.querySelector('.ref-verse-add').textContent = 'Added';
}
renderRefTags();
}
function removeRef(ref) {
modalRefs = modalRefs.filter(r => r !== ref);
renderRefTags();
document.querySelectorAll(`.ref-verse-item[data-ref="${ref}"]`).forEach(el => {
el.classList.remove('added');
el.querySelector('.ref-verse-add').textContent = '+ Add';
});
}
function renderRefTags() {
const container = document.getElementById('qm-ref-tags');
if (modalRefs.length === 0) {
container.innerHTML = '<span style="color:var(--muted);font-size:.78rem">No references added yet</span>';
return;
}
container.innerHTML = modalRefs.map(ref =>
`<span class="ref-tag">${esc(ref)} <button onclick="removeRef('${esc(ref)}')">&times;</button></span>`
).join('');
}
function resetModal() {
document.getElementById('qm-id').value = '';
document.getElementById('qm-text').value = '';
document.getElementById('qm-type').value = 'allusion';
document.getElementById('qm-start').value = '';
document.getElementById('qm-end').value = '';
document.getElementById('ref-book').value = '';
document.getElementById('ref-chapter').innerHTML = '<option value="">Ch.</option>';
document.getElementById('ref-chapter').disabled = true;
document.getElementById('ref-verses-wrap').style.display = 'none';
modalRefs = [];
renderRefTags();
}
function openAddModal() {
loadBibleBooks().then(() => {
document.getElementById('modal-title').textContent = 'Add Quote';
resetModal();
quoteModal.classList.add('active');
});
}
function addFromSelection() {
if (!selRange) return;
selToolbar.classList.remove('visible');
loadBibleBooks().then(() => {
document.getElementById('modal-title').textContent = 'Add Quote';
resetModal();
document.getElementById('qm-text').value = selRange.text;
document.getElementById('qm-start').value = selRange.start;
document.getElementById('qm-end').value = selRange.end;
quoteModal.classList.add('active');
window.getSelection().removeAllRanges();
});
}
function editQuote(idx) {
const a = DATA.annotations[idx];
if (!a) return;
loadBibleBooks().then(() => {
document.getElementById('modal-title').textContent = 'Edit Quote';
resetModal();
document.getElementById('qm-id').value = a.id;
document.getElementById('qm-text').value = a.quote_text;
document.getElementById('qm-type').value = a.quote_type;
document.getElementById('qm-start').value = a.span_start ?? '';
document.getElementById('qm-end').value = a.span_end ?? '';
modalRefs = [...(a.refs || [])];
renderRefTags();
quoteModal.classList.add('active');
});
}
function closeQuoteModal() { quoteModal.classList.remove('active'); }
function saveQuote() {
const id = document.getElementById('qm-id').value;
const text = document.getElementById('qm-text').value.trim();
const type = document.getElementById('qm-type').value;
const startVal = document.getElementById('qm-start').value;
const endVal = document.getElementById('qm-end').value;
const span_start = startVal !== '' ? parseInt(startVal) : null;
const span_end = endVal !== '' ? parseInt(endVal) : null;
if (!text) { showToast('Quote text is required', false); return; }
const saveBtn = document.getElementById('qm-save');
saveBtn.disabled = true;
const payload = {quote_text: text, quote_type: type, references: modalRefs, span_start, span_end};
const url = id ? `/api/quotes/${id}` : '/api/quotes';
const method = id ? 'PUT' : 'POST';
if (!id) payload.source_id = SOURCE_ID;
fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(() => {
closeQuoteModal();
showToast(id ? 'Quote updated' : 'Quote added', true);
distChartsRendered = false;
loadSource();
})
.catch(e => showToast('Error: ' + e.message, false))
.finally(() => { saveBtn.disabled = false; });
}
function deleteQuote(id) {
if (!confirm('Delete this quote?')) return;
fetch(`/api/quotes/${id}`, {method: 'DELETE'})
.then(() => { showToast('Quote deleted', true); distChartsRendered = false; loadSource(); })
.catch(e => showToast('Error: ' + e.message, false));
}
let bookChart = null, typeChart = null;
function loadDistribution() {
fetch(`/api/sources/${SOURCE_ID}/distribution`)
.then(r => r.json())
.then(data => { renderDistCharts(data); distChartsRendered = true; });
}
function renderDistCharts(data) {
if (bookChart) { bookChart.destroy(); bookChart = null; }
if (typeChart) { typeChart.destroy(); typeChart = null; }
const chartFont = { family: "'Inter', system-ui, sans-serif" };
const bookCanvas = document.getElementById('dist-book-chart');
const typeCanvas = document.getElementById('dist-type-chart');
const bd = data.book_distribution || [];
if (bd.length > 0) {
const colors = bd.map(d => d.testament === 'nt' ? '#6366f1' : d.testament === 'ot' ? '#f59e0b' : '#9ca3af');
bookChart = new Chart(bookCanvas, {
type: 'bar',
data: {
labels: bd.map(d => d.book_name || d.book_code),
datasets: [{ data: bd.map(d => d.count), backgroundColor: colors, borderRadius: 6 }],
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { beginAtZero: true, ticks: { precision: 0, font: chartFont }, grid: { color: '#f0ede7' } },
y: { ticks: { font: { ...chartFont, size: 11 } }, grid: { display: false } },
},
},
});
}
const td = data.type_distribution || {};
const typeLabels = ['full', 'partial', 'paraphrase', 'allusion'];
const typeColors = ['#16a34a', '#ca8a04', '#0891b2', '#9333ea'];
const typeData = typeLabels.map(t => td[t] || 0);
if (typeData.some(v => v > 0)) {
typeChart = new Chart(typeCanvas, {
type: 'doughnut',
data: {
labels: typeLabels.map(t => t.charAt(0).toUpperCase() + t.slice(1)),
datasets: [{ data: typeData, backgroundColor: typeColors, borderWidth: 3, borderColor: '#fff', hoverOffset: 8 }],
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: { legend: { position: 'bottom', labels: { padding: 20, font: { ...chartFont, size: 12 }, usePointStyle: true, pointStyle: 'rectRounded' } } },
},
});
}
}
quoteModal.addEventListener('click', e => { if (e.target === quoteModal) closeQuoteModal(); });
loadSource();
</script>
</body>
</html>