| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Scripture Detector — Dashboard</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> |
| .container { max-width: 1400px; } |
| |
| .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
| gap: 16px; margin-bottom: 28px; } |
| .kpi { |
| background: var(--surface); |
| border-radius: var(--radius-lg); |
| padding: 24px; |
| box-shadow: var(--shadow-sm); |
| border: 1px solid var(--border-light); |
| text-align: center; |
| transition: var(--transition); |
| } |
| .kpi:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } |
| .kpi .val { font-size: 2.4rem; font-weight: 900; line-height: 1.1; letter-spacing: -.02em; } |
| .kpi .lbl { font-size: .7rem; color: var(--muted); text-transform: uppercase; |
| letter-spacing: .06em; margin-top: 8px; font-weight: 600; } |
| .kpi-primary .val { color: var(--primary); } |
| .kpi-green .val { color: var(--green); } |
| .kpi-accent .val { color: var(--accent); } |
| .kpi-purple .val { color: var(--allusion); } |
| |
| .cards-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; } |
| @media (max-width: 800px) { .cards-row { grid-template-columns: 1fr; } } |
| |
| .chart-wrap { position: relative; width: 100%; height: 280px; } |
| |
| .type-bar { height: 36px; border-radius: var(--radius-sm); margin-bottom: 12px; } |
| .type-bar div { display: flex; align-items: center; justify-content: center; |
| font-size: .72rem; font-weight: 700; color: #fff; min-width: 28px; transition: width .4s; } |
| |
| .section-title { font-size: .9rem; font-weight: 700; margin-bottom: 16px; |
| text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } |
| |
| table { width: 100%; border-collapse: collapse; font-size: .84rem; } |
| th { text-align: left; padding: 12px 14px; border-bottom: 2px solid var(--border); |
| font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); |
| cursor: pointer; user-select: none; white-space: nowrap; font-weight: 700; } |
| th:hover { color: var(--text); } |
| td { padding: 12px 14px; border-bottom: 1px solid var(--border-light); } |
| tr:hover td { background: var(--bg); } |
| .num { text-align: right; font-variant-numeric: tabular-nums; } |
| .link-cell a { color: var(--primary); text-decoration: none; font-weight: 600; } |
| .link-cell a:hover { text-decoration: underline; } |
| .mini-bar { display: flex; height: 18px; border-radius: 4px; overflow: hidden; min-width: 80px; |
| background: var(--bg-secondary); } |
| .mini-bar div { min-width: 2px; } |
| </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">Analytics Dashboard</div> |
| </div> |
| </a> |
| <nav> |
| <a href="/">Sources</a> |
| <a href="/dashboard" class="active">Dashboard</a> |
| <a href="/about">About</a> |
| <a href="/settings">Settings</a> |
| </nav> |
| </div> |
|
|
| <div class="container" id="app"> |
| <div class="loading"><div class="spinner"></div>Loading dashboard data...</div> |
| </div> |
|
|
| <script> |
| const app = document.getElementById('app'); |
| let RAW = null; |
| let sortCol = 'name', sortAsc = true; |
| |
| function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } |
| |
| function render() { |
| if (!RAW) return; |
| const {source_count, quote_count, reference_count, sources, book_distribution, type_distribution} = RAW; |
| |
| if (source_count === 0) { |
| app.innerHTML = `<div class="empty-state"> |
| <img src="/static/logo.svg" alt="" class="empty-icon"> |
| <h2>No data yet</h2> |
| <p>Add sources and process them to see analytics here.</p> |
| <a href="/" class="btn btn-primary">Go to Sources</a> |
| </div>`; |
| return; |
| } |
| |
| const tc = type_distribution || {}; |
| const totalTypes = Object.values(tc).reduce((s,v) => s + v, 0); |
| const pct = v => totalTypes ? ((v / totalTypes) * 100).toFixed(1) : 0; |
| |
| let sorted = [...(sources || [])]; |
| sorted.sort((x, y) => { |
| let va = x[sortCol] ?? '', vb = y[sortCol] ?? ''; |
| if (typeof va === 'string') { va = va.toLowerCase(); vb = vb.toLowerCase(); } |
| return sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (va > vb ? -1 : va < vb ? 1 : 0); |
| }); |
| const arrow = col => col === sortCol ? (sortAsc ? ' ▲' : ' ▼') : ''; |
| |
| const bd = book_distribution || []; |
| const topBook = bd.length > 0 ? (bd[0].book_name || bd[0].book_code) : '—'; |
| |
| app.innerHTML = ` |
| <div class="kpi-grid"> |
| <div class="kpi kpi-primary"><div class="val">${source_count}</div><div class="lbl">Sources</div></div> |
| <div class="kpi kpi-green"><div class="val">${quote_count}</div><div class="lbl">Quotes Found</div></div> |
| <div class="kpi kpi-accent"><div class="val">${reference_count}</div><div class="lbl">Scripture References</div></div> |
| <div class="kpi"><div class="val">${bd.length}</div><div class="lbl">Books Referenced</div></div> |
| <div class="kpi kpi-purple"><div class="val" style="font-size:1.4rem">${esc(topBook)}</div><div class="lbl">Most Referenced</div></div> |
| </div> |
| |
| <div class="cards-row"> |
| <div class="card"> |
| <h3>Quote Type Distribution</h3> |
| ${totalTypes > 0 ? ` |
| <div class="type-bar"> |
| ${tc.full ? `<div style="width:${pct(tc.full)}%;background:var(--full)">${tc.full}</div>` : ''} |
| ${tc.partial ? `<div style="width:${pct(tc.partial)}%;background:var(--partial)">${tc.partial}</div>` : ''} |
| ${tc.paraphrase ? `<div style="width:${pct(tc.paraphrase)}%;background:var(--paraphrase)">${tc.paraphrase}</div>` : ''} |
| ${tc.allusion ? `<div style="width:${pct(tc.allusion)}%;background:var(--allusion)">${tc.allusion}</div>` : ''} |
| </div> |
| <div class="type-legend"> |
| <div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--full)"></div>Full (${tc.full||0})</div> |
| <div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--partial)"></div>Partial (${tc.partial||0})</div> |
| <div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--paraphrase)"></div>Paraphrase (${tc.paraphrase||0})</div> |
| <div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--allusion)"></div>Allusion (${tc.allusion||0})</div> |
| </div>` : '<p style="color:var(--muted);font-size:.85rem">No quotes detected yet.</p>'} |
| </div> |
| <div class="card"> |
| <h3>Top Bible Books Referenced</h3> |
| <div class="chart-wrap"><canvas id="book-chart"></canvas></div> |
| </div> |
| </div> |
| |
| <div class="cards-row"> |
| <div class="card"> |
| <h3>Scripture Distribution by Testament</h3> |
| <div class="chart-wrap"><canvas id="testament-chart"></canvas></div> |
| </div> |
| <div class="card"> |
| <h3>Quotes per Source</h3> |
| <div class="chart-wrap"><canvas id="source-chart"></canvas></div> |
| </div> |
| </div> |
| |
| <div class="section-title">Per-Source Breakdown</div> |
| <div class="card" style="overflow-x:auto"> |
| <table> |
| <thead><tr> |
| <th data-col="name">Source${arrow('name')}</th> |
| <th data-col="quote_count" class="num">Quotes${arrow('quote_count')}</th> |
| <th>Type Breakdown</th> |
| <th>Top Books</th> |
| <th data-col="created_at">Added${arrow('created_at')}</th> |
| <th></th> |
| </tr></thead> |
| <tbody> |
| ${sorted.map(s => { |
| const td = s.type_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 sbd = s.book_distribution || []; |
| const topBooks = sbd.slice(0,3).map(b => b.book_name || b.book_code).join(', '); |
| return `<tr> |
| <td><strong>${esc(s.name)}</strong></td> |
| <td class="num">${s.quote_count}</td> |
| <td>${total > 0 ? `<div class="mini-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>` : '<span style="color:var(--muted);font-size:.78rem">—</span>'}</td> |
| <td style="font-size:.78rem;color:var(--muted)">${topBooks || '—'}</td> |
| <td style="font-size:.78rem;color:var(--muted)">${new Date(s.created_at).toLocaleDateString()}</td> |
| <td class="link-cell"><a href="/viewer/${s.id}">View</a></td> |
| </tr>`; |
| }).join('')} |
| </tbody> |
| </table> |
| </div>`; |
| |
| document.querySelectorAll('th[data-col]').forEach(th => { |
| th.addEventListener('click', () => { |
| const col = th.dataset.col; |
| if (sortCol === col) sortAsc = !sortAsc; |
| else { sortCol = col; sortAsc = col === 'name'; } |
| render(); |
| }); |
| }); |
| |
| renderCharts(bd, sources); |
| } |
| |
| function renderCharts(bookDist, sources) { |
| const chartFont = { family: "'Inter', system-ui, sans-serif" }; |
| |
| const bookCanvas = document.getElementById('book-chart'); |
| if (bookCanvas && bookDist.length > 0) { |
| const top = bookDist.slice(0, 15); |
| const colors = top.map(d => d.testament === 'nt' ? '#6366f1' : d.testament === 'ot' ? '#f59e0b' : '#9ca3af'); |
| new Chart(bookCanvas, { |
| type: 'bar', |
| data: { |
| labels: top.map(d => d.book_name || d.book_code), |
| datasets: [{ data: top.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 testCanvas = document.getElementById('testament-chart'); |
| if (testCanvas && bookDist.length > 0) { |
| const tally = {ot: 0, nt: 0, ap: 0}; |
| bookDist.forEach(d => { tally[d.testament] = (tally[d.testament] || 0) + d.count; }); |
| const labels = []; const data = []; const colors = []; |
| if (tally.ot) { labels.push('Old Testament'); data.push(tally.ot); colors.push('#f59e0b'); } |
| if (tally.nt) { labels.push('New Testament'); data.push(tally.nt); colors.push('#6366f1'); } |
| if (tally.ap) { labels.push('Apocrypha'); data.push(tally.ap); colors.push('#9ca3af'); } |
| new Chart(testCanvas, { |
| type: 'doughnut', |
| data: { labels, datasets: [{ data, backgroundColor: colors, 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' } } }, |
| }, |
| }); |
| } |
| |
| const srcCanvas = document.getElementById('source-chart'); |
| if (srcCanvas && sources.length > 0) { |
| const sorted = [...sources].sort((a,b) => b.quote_count - a.quote_count).slice(0, 15); |
| new Chart(srcCanvas, { |
| type: 'bar', |
| data: { |
| labels: sorted.map(s => s.name.length > 25 ? s.name.slice(0,22) + '...' : s.name), |
| datasets: [{ data: sorted.map(s => s.quote_count), backgroundColor: '#8b5cf6', 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 } }, |
| }, |
| }, |
| }); |
| } |
| } |
| |
| fetch('/api/dashboard') |
| .then(r => r.json()) |
| .then(data => { RAW = data; render(); }); |
| </script> |
| </body> |
| </html> |
|
|