scripture-detector / templates /dashboard.html
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 — 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 ? ' &#9650;' : ' &#9660;') : '';
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>