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</title> | |
| <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> | |
| <link rel="stylesheet" href="/static/style.css"> | |
| <style> | |
| /* ββ Toolbar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .toolbar { | |
| display: flex; gap: 12px; margin-bottom: 16px; | |
| align-items: center; flex-wrap: wrap; | |
| } | |
| .toolbar .summary { | |
| font-size: .84rem; color: var(--muted); margin-left: auto; | |
| } | |
| .toolbar .summary strong { color: var(--text); font-weight: 700; } | |
| /* ββ Search Panel βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .search-panel { | |
| background: var(--surface); | |
| border: 1.5px solid var(--border); | |
| border-radius: var(--radius-lg); | |
| padding: 16px 20px; | |
| margin-bottom: 24px; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .search-top { display: flex; gap: 10px; align-items: center; } | |
| .search-input-wrap { flex: 1; position: relative; } | |
| .search-input-wrap svg { | |
| position: absolute; left: 11px; top: 50%; | |
| transform: translateY(-50%); color: var(--muted); pointer-events: none; | |
| } | |
| .search-input { | |
| width: 100%; padding: 9px 14px 9px 38px; | |
| border: 1.5px solid var(--border); border-radius: var(--radius-sm); | |
| font-size: .9rem; font-family: inherit; color: var(--text); | |
| transition: var(--transition); | |
| } | |
| .search-input:focus { | |
| outline: none; border-color: var(--primary-mid); | |
| box-shadow: 0 0 0 3px var(--primary-subtle); | |
| } | |
| .search-input.has-value { border-color: var(--primary-mid); } | |
| .search-clear { | |
| position: absolute; right: 10px; top: 50%; transform: translateY(-50%); | |
| background: none; border: none; cursor: pointer; color: var(--muted); | |
| font-size: 1rem; line-height: 1; padding: 2px 4px; border-radius: 3px; | |
| display: none; | |
| } | |
| .search-clear:hover { color: var(--red); } | |
| .adv-toggle { | |
| display: flex; align-items: center; gap: 7px; | |
| padding: 8px 16px; border: 1.5px solid var(--border); | |
| border-radius: var(--radius-sm); background: var(--surface); | |
| cursor: pointer; font-size: .84rem; font-weight: 600; | |
| color: var(--muted); white-space: nowrap; font-family: inherit; | |
| transition: var(--transition); | |
| } | |
| .adv-toggle:hover { border-color: var(--primary-mid); color: var(--primary); } | |
| .adv-toggle.active { | |
| border-color: var(--primary); color: var(--primary); | |
| background: var(--primary-pale); | |
| } | |
| .adv-toggle .badge { | |
| background: var(--primary); color: #fff; | |
| border-radius: 10px; font-size: .68rem; font-weight: 800; | |
| padding: 1px 6px; display: none; | |
| } | |
| .adv-toggle.has-filters .badge { display: inline; } | |
| /* ββ Advanced Panel βββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .adv-panel { | |
| margin-top: 14px; padding-top: 14px; | |
| border-top: 1px solid var(--border-light); | |
| display: none; | |
| } | |
| .adv-panel.visible { display: block; } | |
| .adv-header { | |
| display: flex; align-items: center; gap: 14px; | |
| margin-bottom: 12px; flex-wrap: wrap; | |
| } | |
| .adv-header label { | |
| font-size: .78rem; font-weight: 700; | |
| color: var(--muted); text-transform: uppercase; letter-spacing: .06em; | |
| } | |
| /* Logic toggle pill */ | |
| .logic-toggle { | |
| display: inline-flex; border: 1.5px solid var(--border); | |
| border-radius: var(--radius-sm); overflow: hidden; | |
| } | |
| .logic-btn { | |
| padding: 5px 14px; border: none; background: transparent; | |
| cursor: pointer; font-size: .8rem; font-weight: 700; | |
| color: var(--muted); font-family: inherit; transition: var(--transition); | |
| } | |
| .logic-btn.active { background: var(--primary); color: #fff; } | |
| /* Filter rows */ | |
| .filter-rows { display: flex; flex-direction: column; gap: 8px; } | |
| .filter-row { | |
| display: flex; align-items: center; gap: 8px; flex-wrap: wrap; | |
| padding: 10px 14px; | |
| background: var(--bg); border-radius: var(--radius-sm); | |
| border: 1px solid var(--border-light); | |
| position: relative; | |
| } | |
| .filter-connector { | |
| font-size: .7rem; font-weight: 800; color: var(--primary); | |
| text-transform: uppercase; letter-spacing: .08em; | |
| min-width: 28px; text-align: center; | |
| } | |
| .filter-select, .filter-input { | |
| padding: 6px 10px; border: 1.5px solid var(--border); | |
| border-radius: var(--radius-sm); font-size: .84rem; | |
| font-family: inherit; color: var(--text); background: var(--surface); | |
| transition: var(--transition); | |
| } | |
| .filter-select:focus, .filter-input:focus { | |
| outline: none; border-color: var(--primary-mid); | |
| box-shadow: 0 0 0 3px var(--primary-subtle); | |
| } | |
| .filter-select:disabled { opacity: .4; } | |
| .filter-type { min-width: 130px; } | |
| .filter-book { min-width: 150px; } | |
| .filter-chapter { width: 80px; } | |
| .filter-verse { width: 80px; } | |
| .filter-text { flex: 1; min-width: 160px; } | |
| .filter-remove { | |
| background: none; border: none; cursor: pointer; | |
| color: var(--muted); font-size: 1.1rem; line-height: 1; | |
| padding: 2px 6px; border-radius: 4px; margin-left: auto; | |
| transition: var(--transition); | |
| } | |
| .filter-remove:hover { color: var(--red); background: rgba(198,40,40,.08); } | |
| .add-filter-btn { | |
| display: inline-flex; align-items: center; gap: 5px; | |
| margin-top: 8px; padding: 7px 14px; | |
| border: 1.5px dashed var(--border); border-radius: var(--radius-sm); | |
| background: transparent; cursor: pointer; font-size: .82rem; | |
| font-weight: 600; color: var(--muted); font-family: inherit; | |
| transition: var(--transition); | |
| } | |
| .add-filter-btn:hover { | |
| border-color: var(--primary-mid); color: var(--primary); | |
| background: var(--primary-subtle); | |
| } | |
| /* Active filters summary chip strip */ | |
| .active-chips { | |
| display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; | |
| } | |
| .chip { | |
| display: inline-flex; align-items: center; gap: 5px; | |
| background: var(--primary-pale); color: var(--primary); | |
| border: 1px solid #c9dcf5; border-radius: 20px; | |
| font-size: .76rem; font-weight: 600; padding: 3px 10px; | |
| } | |
| .chip .chip-x { | |
| background: none; border: none; cursor: pointer; | |
| color: var(--primary-mid); font-size: .9rem; line-height: 1; | |
| padding: 0; margin-left: 1px; | |
| } | |
| .chip .chip-x:hover { color: var(--red); } | |
| /* ββ Source Grid ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .source-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); | |
| gap: 20px; | |
| } | |
| .source-card { | |
| background: var(--surface); | |
| border-radius: var(--radius-lg); | |
| box-shadow: var(--shadow-sm); | |
| border: 1.5px solid var(--border-light); | |
| padding: 24px; | |
| transition: all .22s cubic-bezier(.4,0,.2,1); | |
| display: flex; flex-direction: column; gap: 14px; cursor: pointer; | |
| } | |
| .source-card:hover { | |
| box-shadow: var(--shadow-lg); transform: translateY(-3px); | |
| border-color: var(--primary-light); | |
| } | |
| .source-card.search-match { border-color: #c9dcf5; } | |
| .sc-header { display: flex; align-items: flex-start; gap: 12px; } | |
| .sc-name { font-size: 1.05rem; font-weight: 700; flex: 1; line-height: 1.35; } | |
| .sc-date { | |
| font-size: .7rem; color: var(--muted); white-space: nowrap; | |
| background: var(--bg-secondary); padding: 3px 8px; border-radius: 4px; | |
| } | |
| .sc-stats { display: flex; gap: 24px; } | |
| .sc-stat { display: flex; flex-direction: column; } | |
| .sc-stat-val { font-size: 1.4rem; font-weight: 800; } | |
| .sc-stat-lbl { | |
| font-size: .66rem; color: var(--muted); text-transform: uppercase; | |
| letter-spacing: .06em; font-weight: 600; | |
| } | |
| .sc-stat-primary .sc-stat-val { color: var(--primary); } | |
| .sc-stat-accent .sc-stat-val { color: var(--accent); } | |
| .sc-books { | |
| font-size: .78rem; color: var(--muted); line-height: 1.5; | |
| padding: 8px 12px; background: var(--bg); border-radius: var(--radius-sm); | |
| } | |
| .sc-footer { | |
| display: flex; gap: 8px; align-items: center; | |
| margin-top: auto; padding-top: 8px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| /* ββ Match Evidence βββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .match-evidence { | |
| border-radius: var(--radius-sm); | |
| overflow: hidden; | |
| border: 1px solid #c9dcf5; | |
| } | |
| .match-evidence-header { | |
| font-size: .7rem; font-weight: 700; text-transform: uppercase; | |
| letter-spacing: .06em; color: var(--primary); | |
| padding: 5px 10px; background: var(--primary-pale); | |
| display: flex; align-items: center; gap: 5px; | |
| } | |
| .match-item { | |
| padding: 8px 10px; font-size: .8rem; border-top: 1px solid #dce8f8; | |
| background: var(--surface); | |
| } | |
| .match-item:first-child { border-top: none; } | |
| .match-text-snippet { | |
| color: var(--text-secondary); line-height: 1.6; | |
| } | |
| .match-text-snippet mark { | |
| background: #fef08a; color: #713f12; | |
| border-radius: 2px; padding: 1px 3px; font-weight: 800; | |
| box-shadow: 0 0 0 1px #fde047; | |
| } | |
| .match-loc { | |
| font-size: .68rem; color: var(--muted); margin-top: 3px; | |
| font-style: italic; | |
| } | |
| .match-ref-row { | |
| display: flex; align-items: flex-start; gap: 7px; flex-wrap: wrap; | |
| } | |
| .match-ref-badge { | |
| font-family: 'SF Mono', ui-monospace, monospace; | |
| font-size: .72rem; font-weight: 700; | |
| background: var(--bg-secondary); color: var(--primary); | |
| padding: 2px 7px; border-radius: 4px; white-space: nowrap; | |
| border: 1px solid var(--border-light); | |
| } | |
| .match-ref-quote { | |
| color: var(--muted); font-style: italic; | |
| flex: 1; min-width: 0; overflow: hidden; | |
| white-space: nowrap; text-overflow: ellipsis; | |
| } | |
| .no-results { | |
| grid-column: 1/-1; text-align: center; | |
| padding: 60px 20px; color: var(--muted); | |
| } | |
| .no-results h3 { font-size: 1.1rem; color: var(--text); margin-bottom: 8px; } | |
| .no-results p { font-size: .9rem; } | |
| .results-info { | |
| font-size: .82rem; color: var(--muted); margin-bottom: 16px; | |
| display: flex; align-items: center; gap: 8px; | |
| } | |
| .results-info strong { color: var(--text); } | |
| .results-info .clear-link { | |
| color: var(--primary-mid); cursor: pointer; text-decoration: underline; | |
| font-size: .8rem; margin-left: 4px; | |
| } | |
| /* searching state */ | |
| .search-panel.searching .search-input { border-color: var(--primary-mid); } | |
| </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">Scriptural Quote Detection & Analysis</div> | |
| </div> | |
| </a> | |
| <nav> | |
| <a href="/" class="active">Sources</a> | |
| <a href="/dashboard">Dashboard</a> | |
| <a href="/about">About</a> | |
| <a href="/settings">Settings</a> | |
| </nav> | |
| </div> | |
| <div class="container"> | |
| <!-- Toolbar --> | |
| <div class="toolbar" id="toolbar"> | |
| <button class="btn btn-primary" onclick="openModal()"> | |
| <svg width="13" height="13" 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 Source | |
| </button> | |
| <button class="btn btn-ghost" onclick="importZip()" title="Import sources from a ZIP archive exported by Scripture Detector"> | |
| <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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| Import ZIP | |
| </button> | |
| <a class="btn btn-ghost" id="export-zip-btn" href="/api/export/zip" download | |
| title="Export all sources as TEI XML inside a ZIP archive"> | |
| <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 ZIP | |
| </a> | |
| <!-- hidden file input for ZIP import --> | |
| <input type="file" id="zip-file-input" accept=".zip" style="display:none" onchange="handleZipUpload(this)"> | |
| <div class="summary" id="summary-line"></div> | |
| </div> | |
| <!-- Search panel --> | |
| <div class="search-panel" id="search-panel"> | |
| <div class="search-top"> | |
| <!-- Simple text search --> | |
| <div class="search-input-wrap"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> | |
| <input id="text-search" class="search-input" type="text" | |
| placeholder="Search source text contentβ¦" autocomplete="off" | |
| oninput="onTextInput()" onkeydown="if(event.key==='Escape')clearText()"> | |
| <button class="search-clear" id="text-clear" onclick="clearText()" title="Clear">Γ</button> | |
| </div> | |
| <!-- Advanced toggle --> | |
| <button class="adv-toggle" id="adv-toggle" onclick="toggleAdv()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/></svg> | |
| Advanced | |
| <span class="badge" id="adv-badge">0</span> | |
| </button> | |
| </div> | |
| <!-- Advanced filter builder --> | |
| <div class="adv-panel" id="adv-panel"> | |
| <div class="adv-header"> | |
| <label>Combine filters with:</label> | |
| <div class="logic-toggle"> | |
| <button class="logic-btn active" id="logic-and" onclick="setLogic('AND')">AND</button> | |
| <button class="logic-btn" id="logic-or" onclick="setLogic('OR')">OR</button> | |
| </div> | |
| <span style="font-size:.78rem;color:var(--muted)" id="logic-desc"> | |
| Source must match <strong>all</strong> filters | |
| </span> | |
| </div> | |
| <div class="filter-rows" id="filter-rows"></div> | |
| <button class="add-filter-btn" onclick="addFilterRow()"> | |
| <svg width="13" height="13" 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 Filter | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Active filter chips (shown when filters are active) --> | |
| <div class="active-chips" id="active-chips"></div> | |
| <!-- Results info bar (shown during search) --> | |
| <div class="results-info" id="results-info" style="display:none"></div> | |
| <!-- Source grid --> | |
| <div id="source-grid-wrap"> | |
| <div style="text-align:center;padding:60px;color:var(--muted)"> | |
| <div class="spinner" style="margin:0 auto 12px"></div>Loading⦠| |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add source modal --> | |
| <div class="modal-overlay" id="add-modal"> | |
| <div class="modal"> | |
| <h2>Add New Source</h2> | |
| <label for="src-name">Source Name</label> | |
| <input type="text" id="src-name" placeholder="e.g. Augustine, Confessions Book I"> | |
| <label for="src-text">Text</label> | |
| <textarea id="src-text" placeholder="Paste the text to analyze hereβ¦"></textarea> | |
| <div class="actions"> | |
| <button class="btn btn-ghost" onclick="closeModal()">Cancel</button> | |
| <button class="btn btn-primary" id="save-src-btn" onclick="saveSource()">Add Source</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let RAW = null; // full dashboard data | |
| let bibleBooks = null; // [{code, name, testament}] | |
| let advOpen = false; | |
| let currentLogic = 'AND'; | |
| let filterIdSeq = 0; | |
| let filters = []; // [{id, type, value, book, chapter}] | |
| let searchTimer = null; | |
| let isSearching = false; | |
| // ββ Utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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); | |
| } | |
| // ββ Modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const addModal = document.getElementById('add-modal'); | |
| function openModal() { addModal.classList.add('active'); document.getElementById('src-name').focus(); } | |
| function closeModal() { addModal.classList.remove('active'); } | |
| function saveSource() { | |
| const name = document.getElementById('src-name').value.trim(); | |
| const text = document.getElementById('src-text').value.trim(); | |
| if (!name || !text) { showToast('Name and text are required', false); return; } | |
| const btn = document.getElementById('save-src-btn'); | |
| btn.disabled = true; btn.textContent = 'Savingβ¦'; | |
| fetch('/api/sources', { | |
| method: 'POST', headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({name, text}), | |
| }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.error) { showToast(data.error, false); return; } | |
| closeModal(); | |
| document.getElementById('src-name').value = ''; | |
| document.getElementById('src-text').value = ''; | |
| showToast('Source added!', true); | |
| loadAll(); | |
| }) | |
| .catch(e => showToast('Error: ' + e.message, false)) | |
| .finally(() => { btn.disabled = false; btn.textContent = 'Add Source'; }); | |
| } | |
| function deleteSource(e, id, name) { | |
| e.stopPropagation(); | |
| if (!confirm(`Delete "${name}" and all its quotes?`)) return; | |
| fetch(`/api/sources/${id}`, {method: 'DELETE'}) | |
| .then(() => { showToast('Source deleted', true); loadAll(); }) | |
| .catch(e => showToast('Error: ' + e.message, false)); | |
| } | |
| // ββ Text search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function onTextInput() { | |
| const val = document.getElementById('text-search').value; | |
| document.getElementById('text-clear').style.display = val ? 'block' : 'none'; | |
| document.getElementById('text-search').classList.toggle('has-value', !!val); | |
| scheduleSearch(); | |
| } | |
| function clearText() { | |
| document.getElementById('text-search').value = ''; | |
| document.getElementById('text-clear').style.display = 'none'; | |
| document.getElementById('text-search').classList.remove('has-value'); | |
| scheduleSearch(); | |
| } | |
| // ββ Advanced toggle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function toggleAdv() { | |
| advOpen = !advOpen; | |
| document.getElementById('adv-panel').classList.toggle('visible', advOpen); | |
| document.getElementById('adv-toggle').classList.toggle('active', advOpen); | |
| } | |
| function setLogic(l) { | |
| currentLogic = l; | |
| document.getElementById('logic-and').classList.toggle('active', l === 'AND'); | |
| document.getElementById('logic-or').classList.toggle('active', l === 'OR'); | |
| document.getElementById('logic-desc').innerHTML = | |
| l === 'AND' | |
| ? 'Source must match <strong>all</strong> filters' | |
| : 'Source must match <strong>any</strong> filter'; | |
| renderFilterRows(); // update connector labels between rows | |
| scheduleSearch(); | |
| } | |
| // ββ Filter rows ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadBibleBooks() { | |
| if (bibleBooks) return Promise.resolve(); | |
| return fetch('/api/bible/books').then(r => r.json()).then(books => { | |
| bibleBooks = books; | |
| }); | |
| } | |
| function addFilterRow(preset) { | |
| loadBibleBooks().then(() => { | |
| const id = ++filterIdSeq; | |
| const type = preset?.type || 'book'; | |
| filters.push({ id, type, value: preset?.value || '', book: '', chapter: '' }); | |
| renderFilterRows(); | |
| // If preset, update UI after render | |
| if (preset) applyPreset(id, preset); | |
| scheduleSearch(); | |
| }); | |
| } | |
| function applyPreset(id, preset) { | |
| const row = document.querySelector(`.filter-row[data-id="${id}"]`); | |
| if (!row) return; | |
| if (preset.book) { | |
| const bSel = row.querySelector('.filter-book'); | |
| if (bSel) { bSel.value = preset.book; onBookChange(id); } | |
| } | |
| } | |
| function removeFilterRow(id) { | |
| filters = filters.filter(f => f.id !== id); | |
| renderFilterRows(); | |
| updateAdvBadge(); | |
| scheduleSearch(); | |
| } | |
| function renderFilterRows() { | |
| const container = document.getElementById('filter-rows'); | |
| container.innerHTML = ''; | |
| filters.forEach((f, idx) => { | |
| const row = document.createElement('div'); | |
| row.className = 'filter-row'; | |
| row.dataset.id = f.id; | |
| // connector | |
| if (idx > 0) { | |
| const conn = document.createElement('span'); | |
| conn.className = 'filter-connector'; | |
| conn.textContent = currentLogic; | |
| row.appendChild(conn); | |
| } | |
| // type selector | |
| const typeSel = document.createElement('select'); | |
| typeSel.className = 'filter-select filter-type'; | |
| [ | |
| ['book', 'Bible Book'], | |
| ['chapter', 'Chapter'], | |
| ['verse', 'Verse'], | |
| ['text', 'Text Content'], | |
| ].forEach(([val, label]) => { | |
| const opt = document.createElement('option'); | |
| opt.value = val; opt.textContent = label; | |
| if (val === f.type) opt.selected = true; | |
| typeSel.appendChild(opt); | |
| }); | |
| typeSel.addEventListener('change', () => { | |
| const fi = filters.find(x => x.id === f.id); | |
| if (fi) { fi.type = typeSel.value; fi.book = ''; fi.chapter = ''; fi.value = ''; } | |
| renderFilterRows(); | |
| scheduleSearch(); | |
| }); | |
| row.appendChild(typeSel); | |
| if (f.type === 'text') { | |
| // text input | |
| const inp = document.createElement('input'); | |
| inp.type = 'text'; inp.className = 'filter-select filter-text'; | |
| inp.placeholder = 'Search textβ¦'; inp.value = f.value; | |
| inp.addEventListener('input', () => { | |
| const fi = filters.find(x => x.id === f.id); | |
| if (fi) fi.value = inp.value; | |
| scheduleSearch(); | |
| }); | |
| row.appendChild(inp); | |
| } else { | |
| // book dropdown | |
| const bookSel = document.createElement('select'); | |
| bookSel.className = 'filter-select filter-book'; | |
| const defOpt = document.createElement('option'); | |
| defOpt.value = ''; defOpt.textContent = 'Select bookβ¦'; | |
| bookSel.appendChild(defOpt); | |
| (bibleBooks || []).forEach(b => { | |
| const opt = document.createElement('option'); | |
| opt.value = b.code; | |
| opt.textContent = b.name; | |
| if (b.code === f.book) opt.selected = true; | |
| bookSel.appendChild(opt); | |
| }); | |
| bookSel.addEventListener('change', () => { | |
| const fi = filters.find(x => x.id === f.id); | |
| if (fi) { fi.book = bookSel.value; fi.chapter = ''; fi.value = fi.book; } | |
| onBookChange(f.id); | |
| scheduleSearch(); | |
| }); | |
| row.appendChild(bookSel); | |
| if (f.type === 'chapter' || f.type === 'verse') { | |
| // chapter dropdown | |
| const chSel = document.createElement('select'); | |
| chSel.className = 'filter-select filter-chapter'; | |
| chSel.disabled = !f.book; | |
| const chDef = document.createElement('option'); | |
| chDef.value = ''; chDef.textContent = 'Ch.'; | |
| chSel.appendChild(chDef); | |
| chSel.dataset.book = f.book; | |
| chSel.addEventListener('change', () => { | |
| const fi = filters.find(x => x.id === f.id); | |
| if (fi) { | |
| fi.chapter = chSel.value; | |
| fi.value = fi.chapter ? `${fi.book}_${fi.chapter}` : fi.book; | |
| } | |
| if (f.type === 'verse') onChapterChange(f.id); | |
| scheduleSearch(); | |
| }); | |
| row.appendChild(chSel); | |
| row.dataset.chSel = true; | |
| // load chapters if book already selected | |
| if (f.book) { | |
| fetch(`/api/bible/${f.book}/chapters`).then(r => r.json()).then(chs => { | |
| chs.forEach(ch => { | |
| const opt = document.createElement('option'); | |
| opt.value = ch; opt.textContent = ch; | |
| if (String(ch) === String(f.chapter)) opt.selected = true; | |
| chSel.appendChild(opt); | |
| }); | |
| chSel.disabled = false; | |
| }); | |
| } | |
| if (f.type === 'verse') { | |
| // verse number input | |
| const vInp = document.createElement('input'); | |
| vInp.type = 'number'; vInp.min = '1'; | |
| vInp.className = 'filter-select filter-verse'; | |
| vInp.placeholder = 'Verse #'; | |
| vInp.disabled = !f.chapter; | |
| if (f.verse) vInp.value = f.verse; | |
| vInp.addEventListener('input', () => { | |
| const fi = filters.find(x => x.id === f.id); | |
| if (fi) { | |
| fi.verse = vInp.value; | |
| fi.value = fi.chapter && vInp.value | |
| ? `${fi.book}_${fi.chapter}:${vInp.value}` | |
| : (fi.chapter ? `${fi.book}_${fi.chapter}` : fi.book); | |
| } | |
| scheduleSearch(); | |
| }); | |
| row.appendChild(vInp); | |
| } | |
| } | |
| } | |
| // remove button | |
| const rem = document.createElement('button'); | |
| rem.className = 'filter-remove'; rem.title = 'Remove filter'; | |
| rem.innerHTML = '×'; | |
| rem.onclick = () => removeFilterRow(f.id); | |
| row.appendChild(rem); | |
| container.appendChild(row); | |
| }); | |
| updateAdvBadge(); | |
| renderChips(); | |
| } | |
| function onBookChange(fid) { | |
| const fi = filters.find(x => x.id === fid); | |
| if (!fi) return; | |
| fi.chapter = ''; fi.verse = ''; | |
| fi.value = fi.book; | |
| const row = document.querySelector(`.filter-row[data-id="${fid}"]`); | |
| if (!row) return; | |
| const chSel = row.querySelector('.filter-chapter'); | |
| const vInp = row.querySelector('.filter-verse'); | |
| if (chSel) { | |
| chSel.innerHTML = '<option value="">Ch.</option>'; | |
| chSel.disabled = !fi.book; | |
| if (fi.book) { | |
| fetch(`/api/bible/${fi.book}/chapters`).then(r => r.json()).then(chs => { | |
| chs.forEach(ch => { | |
| const opt = document.createElement('option'); | |
| opt.value = ch; opt.textContent = ch; | |
| chSel.appendChild(opt); | |
| }); | |
| }); | |
| } | |
| } | |
| if (vInp) { vInp.value = ''; vInp.disabled = true; } | |
| } | |
| function onChapterChange(fid) { | |
| const fi = filters.find(x => x.id === fid); | |
| if (!fi) return; | |
| const row = document.querySelector(`.filter-row[data-id="${fid}"]`); | |
| if (!row) return; | |
| const vInp = row.querySelector('.filter-verse'); | |
| if (vInp) { vInp.value = ''; vInp.disabled = !fi.chapter; } | |
| } | |
| function updateAdvBadge() { | |
| const active = filters.filter(f => f.value).length; | |
| const badge = document.getElementById('adv-badge'); | |
| badge.textContent = active; | |
| document.getElementById('adv-toggle').classList.toggle('has-filters', active > 0); | |
| } | |
| // ββ Chip strip (active filter summary) ββββββββββββββββββββββββββββββββββββββ | |
| function renderChips() { | |
| const container = document.getElementById('active-chips'); | |
| const textVal = document.getElementById('text-search').value.trim(); | |
| const activeFilters = filters.filter(f => f.value); | |
| container.innerHTML = ''; | |
| if (textVal) { | |
| const chip = makeChip(`Text: "${textVal}"`, clearText); | |
| container.appendChild(chip); | |
| } | |
| activeFilters.forEach(f => { | |
| let label = ''; | |
| if (f.type === 'book') label = `Book: ${bookName(f.book)}`; | |
| else if (f.type === 'chapter') label = `Chapter: ${bookName(f.book)} ${f.chapter}`; | |
| else if (f.type === 'verse') label = `Verse: ${f.value}`; | |
| else if (f.type === 'text') label = `Text: "${f.value}"`; | |
| if (label) { | |
| const chip = makeChip(label, () => removeFilterRow(f.id)); | |
| container.appendChild(chip); | |
| } | |
| }); | |
| } | |
| function makeChip(label, onRemove) { | |
| const chip = document.createElement('span'); | |
| chip.className = 'chip'; | |
| chip.innerHTML = `${esc(label)} <button class="chip-x" title="Remove">×</button>`; | |
| chip.querySelector('.chip-x').onclick = onRemove; | |
| return chip; | |
| } | |
| function bookName(code) { | |
| if (!bibleBooks) return code; | |
| const b = bibleBooks.find(x => x.code === code); | |
| return b ? b.name : code; | |
| } | |
| // ββ Search scheduling ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function scheduleSearch() { | |
| clearTimeout(searchTimer); | |
| renderChips(); | |
| searchTimer = setTimeout(executeSearch, 320); | |
| } | |
| function executeSearch() { | |
| const textVal = document.getElementById('text-search').value.trim(); | |
| const structFilters = filters.filter(f => f.value.trim()); | |
| // If nothing active, show all sources from RAW | |
| if (!textVal && structFilters.length === 0) { | |
| renderSources(null); | |
| return; | |
| } | |
| const allFilters = []; | |
| if (textVal) allFilters.push({ type: 'text', value: textVal }); | |
| structFilters.forEach(f => allFilters.push({ type: f.type, value: f.value })); | |
| isSearching = true; | |
| document.getElementById('search-panel').classList.add('searching'); | |
| fetch('/api/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ filters: allFilters, logic: currentLogic }), | |
| }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| isSearching = false; | |
| document.getElementById('search-panel').classList.remove('searching'); | |
| renderSources(data); | |
| }) | |
| .catch(e => { | |
| isSearching = false; | |
| document.getElementById('search-panel').classList.remove('searching'); | |
| showToast('Search error: ' + e.message, false); | |
| }); | |
| } | |
| // ββ Rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderSources(searchResult) { | |
| const wrap = document.getElementById('source-grid-wrap'); | |
| const infoBar = document.getElementById('results-info'); | |
| // Determine what to show | |
| let sources, isFiltered, totalCount; | |
| if (searchResult === null) { | |
| // Show all from dashboard | |
| if (!RAW) return; | |
| sources = RAW.sources.map(s => ({ | |
| ...s, | |
| type_distribution: s.type_distribution || {}, | |
| book_distribution: s.book_distribution || [], | |
| match_evidence: null, | |
| })); | |
| isFiltered = false; | |
| totalCount = RAW.source_count; | |
| infoBar.style.display = 'none'; | |
| document.getElementById('summary-line').innerHTML = | |
| `<strong>${RAW.source_count}</strong> source${RAW.source_count !== 1 ? 's' : ''} | |
| · | |
| <strong>${RAW.quote_count}</strong> quote${RAW.quote_count !== 1 ? 's' : ''} | |
| · | |
| <strong>${RAW.reference_count}</strong> reference${RAW.reference_count !== 1 ? 's' : ''}`; | |
| } else { | |
| sources = searchResult.results; | |
| isFiltered = true; | |
| totalCount = RAW ? RAW.source_count : '?'; | |
| const n = searchResult.total; | |
| infoBar.style.display = 'flex'; | |
| infoBar.innerHTML = ` | |
| Showing <strong>${n}</strong> of <strong>${totalCount}</strong> source${totalCount !== 1 ? 's' : ''} | |
| matching your ${currentLogic === 'AND' ? 'AND' : 'OR'} filters | |
| <span class="clear-link" onclick="clearAllFilters()">Clear all filters</span>`; | |
| document.getElementById('summary-line').innerHTML = | |
| `<strong>${n}</strong> result${n !== 1 ? 's' : ''}`; | |
| } | |
| // Empty state | |
| if (sources.length === 0 && !isFiltered) { | |
| wrap.innerHTML = ` | |
| <div class="empty-state"> | |
| <img src="/static/logo.svg" alt="" class="empty-icon"> | |
| <h2>No sources yet</h2> | |
| <p>Add a text source to get started with scripture detection and analysis.</p> | |
| <button class="btn btn-primary btn-lg" onclick="openModal()"> | |
| <svg width="16" height="16" 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 Your First Source | |
| </button> | |
| </div>`; | |
| return; | |
| } | |
| const grid = document.createElement('div'); | |
| grid.className = 'source-grid'; | |
| if (isFiltered && sources.length === 0) { | |
| grid.innerHTML = ` | |
| <div class="no-results"> | |
| <h3>No matching sources</h3> | |
| <p>Try adjusting your filters or switching from AND to OR logic.</p> | |
| </div>`; | |
| wrap.innerHTML = ''; wrap.appendChild(grid); return; | |
| } | |
| sources.forEach(s => { | |
| const td = s.type_distribution || {}; | |
| const bd = s.book_distribution || []; | |
| const total = (td.full||0) + (td.partial||0) + (td.paraphrase||0) + (td.allusion||0); | |
| const p = v => total ? ((v / total) * 100).toFixed(0) : 0; | |
| const topBooks = bd.slice(0, 4).map(b => b.book_name || b.book_code); | |
| const booksText = topBooks.length > 0 | |
| ? topBooks.join(', ') + (bd.length > 4 ? ` +${bd.length - 4} more` : '') | |
| : 'No books referenced yet'; | |
| const card = document.createElement('div'); | |
| card.className = 'source-card' + (isFiltered ? ' search-match' : ''); | |
| card.onclick = () => window.location.href = '/viewer/' + s.id; | |
| // Match evidence HTML | |
| let evidenceHtml = ''; | |
| if (s.match_evidence && s.match_evidence.length > 0) { | |
| const items = s.match_evidence.map(ev => { | |
| if (ev.kind === 'text') { | |
| if (ev.offset < 0) { | |
| // Name-only match | |
| return `<div class="match-item"> | |
| <div class="match-text-snippet"> | |
| Matched source name: <mark>${esc(ev.query)}</mark> | |
| </div> | |
| </div>`; | |
| } | |
| const hl = highlightMatch(ev.snippet, ev.query); | |
| const pos = ev.offset > 0 ? `~${Math.round(ev.offset / 100) * 100} chars in` : 'start of text'; | |
| return `<div class="match-item"> | |
| <div class="match-text-snippet">${hl}</div> | |
| <div class="match-loc">Found at ${pos}</div> | |
| </div>`; | |
| } else { | |
| const qt = `<span class="type-badge type-${esc(ev.quote_type)}">${esc(ev.quote_type)}</span>`; | |
| return `<div class="match-item"> | |
| <div class="match-ref-row"> | |
| <span class="match-ref-badge">${esc(ev.reference)}</span> | |
| ${qt} | |
| <span class="match-ref-quote">"${esc(ev.quote_text)}"</span> | |
| </div> | |
| </div>`; | |
| } | |
| }).join(''); | |
| evidenceHtml = ` | |
| <div class="match-evidence"> | |
| <div class="match-evidence-header"> | |
| <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> | |
| Match Evidence | |
| </div> | |
| ${items} | |
| </div>`; | |
| } | |
| card.innerHTML = ` | |
| <div class="sc-header"> | |
| <div class="sc-name">${esc(s.name)}</div> | |
| <div class="sc-date">${new Date(s.created_at).toLocaleDateString()}</div> | |
| </div> | |
| <div class="sc-stats"> | |
| <div class="sc-stat sc-stat-primary"> | |
| <div class="sc-stat-val">${s.quote_count}</div> | |
| <div class="sc-stat-lbl">Quotes</div> | |
| </div> | |
| <div class="sc-stat sc-stat-accent"> | |
| <div class="sc-stat-val">${bd.length}</div> | |
| <div class="sc-stat-lbl">Books</div> | |
| </div> | |
| </div> | |
| ${total > 0 ? ` | |
| <div> | |
| <div class="type-bar"> | |
| ${td.full ? `<div style="width:${p(td.full)}%;background:var(--full)"></div>` : ''} | |
| ${td.partial ? `<div style="width:${p(td.partial)}%;background:var(--partial)"></div>` : ''} | |
| ${td.paraphrase? `<div style="width:${p(td.paraphrase)}%;background:var(--paraphrase)"></div>` : ''} | |
| ${td.allusion ? `<div style="width:${p(td.allusion)}%;background:var(--allusion)"></div>` : ''} | |
| </div> | |
| <div class="type-legend"> | |
| ${td.full ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--full)"></div>${td.full} Full</div>` : ''} | |
| ${td.partial ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--partial)"></div>${td.partial} Partial</div>` : ''} | |
| ${td.paraphrase? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--paraphrase)"></div>${td.paraphrase} Paraphrase</div>` : ''} | |
| ${td.allusion ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--allusion)"></div>${td.allusion} Allusion</div>` : ''} | |
| </div> | |
| </div>` : '<div style="font-size:.78rem;color:var(--muted);font-style:italic">Not yet processed</div>'} | |
| ${evidenceHtml || `<div class="sc-books">${esc(booksText)}</div>`} | |
| <div class="sc-footer"> | |
| <button class="btn btn-primary btn-sm" | |
| onclick="event.stopPropagation();window.location.href='/viewer/${s.id}'"> | |
| View | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> | |
| </button> | |
| <div style="flex:1"></div> | |
| <button class="btn btn-ghost btn-sm" style="color:var(--red)" | |
| onclick="deleteSource(event,${s.id},'${esc(s.name).replace(/'/g,"\\'")}')">Delete</button> | |
| </div>`; | |
| grid.appendChild(card); | |
| }); | |
| wrap.innerHTML = ''; wrap.appendChild(grid); | |
| } | |
| function highlightMatch(snippet, query) { | |
| if (!query) return esc(snippet); | |
| // esc() the snippet first, then highlight the query within the escaped HTML | |
| const escaped = esc(snippet); | |
| const qEsc = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| // The query arrives lowercased from the API; match case-insensitively | |
| return escaped.replace(new RegExp(qEsc, 'gi'), m => `<mark>${m}</mark>`); | |
| } | |
| function clearAllFilters() { | |
| clearText(); | |
| filters = []; | |
| renderFilterRows(); | |
| if (advOpen) { | |
| document.getElementById('adv-panel').classList.remove('visible'); | |
| document.getElementById('adv-toggle').classList.remove('active'); | |
| advOpen = false; | |
| } | |
| renderSources(null); | |
| document.getElementById('active-chips').innerHTML = ''; | |
| document.getElementById('results-info').style.display = 'none'; | |
| } | |
| // ββ ZIP Import βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function importZip() { | |
| document.getElementById('zip-file-input').click(); | |
| } | |
| function handleZipUpload(input) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| input.value = ''; // reset so same file can be re-selected | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| // Show a toast while importing | |
| const prog = document.createElement('div'); | |
| prog.className = 'toast toast-ok'; | |
| prog.style.opacity = '1'; | |
| prog.textContent = `Importing ${file.name}β¦`; | |
| document.body.appendChild(prog); | |
| fetch('/api/import/zip', { method: 'POST', body: formData }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| prog.remove(); | |
| if (data.error) { showToast(data.error, false); return; } | |
| const errMsg = data.errors && data.errors.length | |
| ? ` (${data.errors.length} file${data.errors.length !== 1 ? 's' : ''} failed)` | |
| : ''; | |
| showToast(`Imported ${data.imported} source${data.imported !== 1 ? 's' : ''}${errMsg}`, true); | |
| loadAll(); | |
| }) | |
| .catch(e => { prog.remove(); showToast('Import error: ' + e.message, false); }); | |
| } | |
| // ββ Initial load βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadAll() { | |
| // /api/dashboard already includes per-source type_distribution & book_distribution | |
| fetch('/api/dashboard') | |
| .then(r => r.json()) | |
| .then(data => { RAW = data; renderSources(null); }); | |
| } | |
| addModal.addEventListener('click', e => { if (e.target === addModal) closeModal(); }); | |
| loadAll(); | |
| </script> | |
| </body> | |
| </html> | |