Spaces:
Sleeping
Sleeping
| <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) ; } | |
| .ann-partial { border-left: 4px solid var(--partial) ; } | |
| .ann-paraphrase { border-left: 4px solid var(--paraphrase) ; } | |
| .ann-allusion { border-left: 4px solid var(--allusion) ; } | |
| .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)}')">×</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> | |