add admin interface with article and event management features
This commit is contained in:
parent
81bcf40a8d
commit
715172596f
6 changed files with 964 additions and 29 deletions
692
admin.html
Normal file
692
admin.html
Normal file
|
|
@ -0,0 +1,692 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #0f1117;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: #1a1d27;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #2d3148;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #141620;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .label { color: #64748b; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.stat .value { font-size: 20px; font-weight: 600; color: #fff; }
|
||||||
|
|
||||||
|
.content { padding: 20px 24px; }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #94a3b8; }
|
||||||
|
|
||||||
|
input[type="text"], input[type="date"], select {
|
||||||
|
background: #1a1d27;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus, select:focus {
|
||||||
|
border-color: #4f6ef7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #2d3148;
|
||||||
|
border: none;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: #3a3f5c; }
|
||||||
|
button.primary { background: #4f6ef7; color: #fff; }
|
||||||
|
button.primary:hover { background: #3d5ce0; }
|
||||||
|
button.danger { background: #7f1d1d; color: #fca5a5; }
|
||||||
|
button.danger:hover { background: #991b1b; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #1e2130;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td { background: #161926; }
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok { background: #14532d; color: #86efac; }
|
||||||
|
.badge.err { background: #7f1d1d; color: #fca5a5; }
|
||||||
|
.badge.pending { background: #1e3a5f; color: #93c5fd; }
|
||||||
|
.badge.null { background: #1e293b; color: #94a3b8; }
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1a1d27;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 680px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 { font-size: 16px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.field label { font-size: 12px; color: #94a3b8; }
|
||||||
|
|
||||||
|
.field input[type="text"],
|
||||||
|
.field textarea,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
background: #141620;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus { border-color: #4f6ef7; }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-link { color: #818cf8; text-decoration: none; }
|
||||||
|
.url-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: none;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast.show { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Duriin Admin</h1>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="articles">Articles</button>
|
||||||
|
<button class="tab-btn" data-tab="events">Events</button>
|
||||||
|
<button class="tab-btn" data-tab="stats">Stats</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||||||
|
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- Articles tab -->
|
||||||
|
<div id="tab-articles">
|
||||||
|
<div class="filters">
|
||||||
|
<label>Keyword <input type="text" id="f-keyword" placeholder="search..." /></label>
|
||||||
|
<label>Source <select id="f-source"><option value="">All sources</option></select></label>
|
||||||
|
<label>Status
|
||||||
|
<select id="f-status">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="null">no status</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>From <input type="date" id="f-from" /></label>
|
||||||
|
<label>To <input type="date" id="f-to" /></label>
|
||||||
|
<button class="primary" id="searchBtn" style="align-self:flex-end">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44px">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ingested</th>
|
||||||
|
<th style="width:80px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="articleTable"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="prevBtn">← Prev</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button id="nextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events tab -->
|
||||||
|
<div id="tab-events" style="display:none">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44px">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style="width:100px">Articles</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th style="width:80px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="eventTable"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="ePrevBtn">← Prev</button>
|
||||||
|
<span id="ePageInfo"></span>
|
||||||
|
<button id="eNextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats tab -->
|
||||||
|
<div id="tab-stats" style="display:none">
|
||||||
|
<div style="display:flex; gap:32px; flex-wrap:wrap; padding-top:8px">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:13px; color:#94a3b8; margin-bottom:10px; text-transform:uppercase; letter-spacing:.05em">By source</h3>
|
||||||
|
<table style="width:auto">
|
||||||
|
<thead><tr><th>Source</th><th style="text-align:right">Count</th></tr></thead>
|
||||||
|
<tbody id="sourceTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:13px; color:#94a3b8; margin-bottom:10px; text-transform:uppercase; letter-spacing:.05em">By content status</h3>
|
||||||
|
<table style="width:auto">
|
||||||
|
<thead><tr><th>Status</th><th style="text-align:right">Count</th></tr></thead>
|
||||||
|
<tbody id="statusTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Article modal -->
|
||||||
|
<div class="overlay" id="articleOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modalTitle">Article</h2>
|
||||||
|
<div id="modalMeta" style="font-size:12px; color:#64748b; margin-bottom:16px; display:flex; gap:16px; flex-wrap:wrap"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input type="text" id="m-title" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea id="m-desc" style="min-height:70px"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Content</label>
|
||||||
|
<textarea id="m-content" style="min-height:200px"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap">
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Content status</label>
|
||||||
|
<select id="m-status">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Language</label>
|
||||||
|
<input type="text" id="m-lang" placeholder="en" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Pub date</label>
|
||||||
|
<input type="text" id="m-pubdate" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Is index page</label>
|
||||||
|
<select id="m-indexpage">
|
||||||
|
<option value="0">No</option>
|
||||||
|
<option value="1">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="danger" id="deleteBtn">Delete</button>
|
||||||
|
<button id="cancelBtn">Cancel</button>
|
||||||
|
<button class="primary" id="saveBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event edit modal -->
|
||||||
|
<div class="overlay" id="eventOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Edit Event</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input type="text" id="em-title" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="danger" id="eDeleteBtn">Delete event</button>
|
||||||
|
<button id="eCancelBtn">Cancel</button>
|
||||||
|
<button class="primary" id="eSaveBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const api = (path, opts) => fetch(path, opts).then(r => {
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toast(msg, err) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'show';
|
||||||
|
if (err) el.style.borderColor = '#ef4444';
|
||||||
|
else el.style.borderColor = '#334155';
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => el.className = '', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeHtml(status) {
|
||||||
|
const s = status || 'null';
|
||||||
|
const cls = s === 'ok' ? 'ok' : s === 'error' ? 'err' : s === 'pending' ? 'pending' : 'null';
|
||||||
|
return `<span class="badge ${cls}">${s}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── stats ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const data = await api('/admin/api/stats');
|
||||||
|
document.getElementById('s-total').textContent = data.total.toLocaleString();
|
||||||
|
document.getElementById('s-content').textContent = data.withContent.toLocaleString();
|
||||||
|
document.getElementById('s-embed').textContent = data.withEmbedding.toLocaleString();
|
||||||
|
document.getElementById('s-events').textContent = data.eventCount.toLocaleString();
|
||||||
|
|
||||||
|
document.getElementById('sourceTable').innerHTML = data.bySource
|
||||||
|
.map(r => `<tr><td>${r.source}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join('');
|
||||||
|
|
||||||
|
document.getElementById('statusTable').innerHTML = data.byStatus
|
||||||
|
.map(r => `<tr><td>${badgeHtml(r.status === 'null' ? null : r.status)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── source dropdown ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
const sources = await api('/admin/api/sources');
|
||||||
|
const sel = document.getElementById('f-source');
|
||||||
|
sources.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s;
|
||||||
|
opt.textContent = s;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── articles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let articleOffset = 0;
|
||||||
|
const PAGE = 50;
|
||||||
|
let currentArticle = null;
|
||||||
|
|
||||||
|
function getFilters() {
|
||||||
|
return {
|
||||||
|
keyword: document.getElementById('f-keyword').value.trim(),
|
||||||
|
source: document.getElementById('f-source').value,
|
||||||
|
content_status: document.getElementById('f-status').value,
|
||||||
|
from: document.getElementById('f-from').value,
|
||||||
|
to: document.getElementById('f-to').value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArticles() {
|
||||||
|
const f = getFilters();
|
||||||
|
const params = new URLSearchParams({ limit: PAGE, offset: articleOffset });
|
||||||
|
if (f.keyword) params.set('keyword', f.keyword);
|
||||||
|
if (f.source) params.set('source', f.source);
|
||||||
|
if (f.content_status) params.set('content_status', f.content_status);
|
||||||
|
if (f.from) params.set('from', f.from + 'T00:00:00');
|
||||||
|
if (f.to) params.set('to', f.to + 'T23:59:59');
|
||||||
|
|
||||||
|
const data = await api(`/admin/api/articles?${params}`);
|
||||||
|
const tbody = document.getElementById('articleTable');
|
||||||
|
|
||||||
|
tbody.innerHTML = data.rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="color:#64748b">${r.id}</td>
|
||||||
|
<td><span class="truncate" title="${r.title}">${r.title}</span>
|
||||||
|
<a class="url-link" href="${r.url}" target="_blank" style="font-size:11px; display:block; margin-top:2px">↗ ${new URL(r.url).hostname}</a>
|
||||||
|
</td>
|
||||||
|
<td><span style="color:#94a3b8">${r.source}</span></td>
|
||||||
|
<td>${badgeHtml(r.content_status)}</td>
|
||||||
|
<td style="color:#64748b; white-space:nowrap">${r.ingested_at ? r.ingested_at.slice(0, 16) : '—'}</td>
|
||||||
|
<td><button onclick="openArticle(${r.id})">Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById('pageInfo').textContent =
|
||||||
|
`${articleOffset + 1}–${Math.min(articleOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
|
||||||
|
document.getElementById('prevBtn').disabled = articleOffset === 0;
|
||||||
|
document.getElementById('nextBtn').disabled = articleOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchBtn').onclick = () => { articleOffset = 0; loadArticles(); };
|
||||||
|
document.getElementById('prevBtn').onclick = () => { articleOffset = Math.max(0, articleOffset - PAGE); loadArticles(); };
|
||||||
|
document.getElementById('nextBtn').onclick = () => { articleOffset += PAGE; loadArticles(); };
|
||||||
|
|
||||||
|
// enter key in filters triggers search
|
||||||
|
document.querySelector('.filters').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { articleOffset = 0; loadArticles(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── article modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openArticle(id) {
|
||||||
|
currentArticle = await api(`/admin/api/articles/${id}`);
|
||||||
|
const a = currentArticle;
|
||||||
|
|
||||||
|
document.getElementById('modalTitle').textContent = `Article #${a.id}`;
|
||||||
|
|
||||||
|
document.getElementById('modalMeta').innerHTML = [
|
||||||
|
a.source && `<span>source: <b>${a.source}</b></span>`,
|
||||||
|
a.pub_date && `<span>pub: ${a.pub_date.slice(0, 16)}</span>`,
|
||||||
|
`<span>has_embedding: ${a.has_embedding ? 'yes' : 'no'}</span>`,
|
||||||
|
a.content_error && `<span style="color:#fca5a5">error: ${a.content_error.slice(0, 80)}</span>`,
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
document.getElementById('m-title').value = a.title || '';
|
||||||
|
document.getElementById('m-desc').value = a.description || '';
|
||||||
|
document.getElementById('m-content').value = a.content || '';
|
||||||
|
document.getElementById('m-status').value = a.content_status || '';
|
||||||
|
document.getElementById('m-lang').value = a.language || '';
|
||||||
|
document.getElementById('m-pubdate').value = a.pub_date || '';
|
||||||
|
document.getElementById('m-indexpage').value = String(a.is_index_page || 0);
|
||||||
|
|
||||||
|
document.getElementById('articleOverlay').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('cancelBtn').onclick = () =>
|
||||||
|
document.getElementById('articleOverlay').classList.remove('open');
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').onclick = async () => {
|
||||||
|
if (!currentArticle) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/articles/${currentArticle.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: document.getElementById('m-title').value,
|
||||||
|
description: document.getElementById('m-desc').value,
|
||||||
|
content: document.getElementById('m-content').value,
|
||||||
|
content_status: document.getElementById('m-status').value || null,
|
||||||
|
language: document.getElementById('m-lang').value || null,
|
||||||
|
pub_date: document.getElementById('m-pubdate').value || null,
|
||||||
|
is_index_page: parseInt(document.getElementById('m-indexpage').value, 10),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById('articleOverlay').classList.remove('open');
|
||||||
|
toast('Saved');
|
||||||
|
loadArticles();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Save failed', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('deleteBtn').onclick = async () => {
|
||||||
|
if (!currentArticle) return;
|
||||||
|
if (!confirm(`Delete article #${currentArticle.id}? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/articles/${currentArticle.id}`, { method: 'DELETE' });
|
||||||
|
document.getElementById('articleOverlay').classList.remove('open');
|
||||||
|
toast('Deleted');
|
||||||
|
loadArticles();
|
||||||
|
loadStats();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Delete failed', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── events ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let eventOffset = 0;
|
||||||
|
let currentEvent = null;
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const data = await api(`/admin/api/events?limit=${PAGE}&offset=${eventOffset}`);
|
||||||
|
const tbody = document.getElementById('eventTable');
|
||||||
|
|
||||||
|
tbody.innerHTML = data.rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="color:#64748b">${r.id}</td>
|
||||||
|
<td><span class="truncate" title="${r.title}">${r.title}</span></td>
|
||||||
|
<td>${r.article_count}</td>
|
||||||
|
<td style="color:#64748b; white-space:nowrap">${r.created_at ? r.created_at.slice(0, 16) : '—'}</td>
|
||||||
|
<td><button onclick="openEvent(${r.id}, ${JSON.stringify(r.title).replace(/"/g, '"')})">Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById('ePageInfo').textContent =
|
||||||
|
`${eventOffset + 1}–${Math.min(eventOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
document.getElementById('ePrevBtn').disabled = eventOffset === 0;
|
||||||
|
document.getElementById('eNextBtn').disabled = eventOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ePrevBtn').onclick = () => { eventOffset = Math.max(0, eventOffset - PAGE); loadEvents(); };
|
||||||
|
document.getElementById('eNextBtn').onclick = () => { eventOffset += PAGE; loadEvents(); };
|
||||||
|
|
||||||
|
function openEvent(id, title) {
|
||||||
|
currentEvent = { id, title };
|
||||||
|
document.getElementById('em-title').value = title;
|
||||||
|
document.getElementById('eventOverlay').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('eCancelBtn').onclick = () =>
|
||||||
|
document.getElementById('eventOverlay').classList.remove('open');
|
||||||
|
|
||||||
|
document.getElementById('eSaveBtn').onclick = async () => {
|
||||||
|
if (!currentEvent) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/events/${currentEvent.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: document.getElementById('em-title').value }),
|
||||||
|
});
|
||||||
|
document.getElementById('eventOverlay').classList.remove('open');
|
||||||
|
toast('Saved');
|
||||||
|
loadEvents();
|
||||||
|
} catch (e) { toast('Save failed', true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('eDeleteBtn').onclick = async () => {
|
||||||
|
if (!currentEvent) return;
|
||||||
|
if (!confirm(`Delete event #${currentEvent.id}? Articles will be detached but not deleted.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/events/${currentEvent.id}`, { method: 'DELETE' });
|
||||||
|
document.getElementById('eventOverlay').classList.remove('open');
|
||||||
|
toast('Event deleted');
|
||||||
|
loadEvents();
|
||||||
|
loadStats();
|
||||||
|
} catch (e) { toast('Delete failed', true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── tabs ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tabContents = {
|
||||||
|
articles: document.getElementById('tab-articles'),
|
||||||
|
events: document.getElementById('tab-events'),
|
||||||
|
stats: document.getElementById('tab-stats'),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const tab = btn.dataset.tab;
|
||||||
|
Object.entries(tabContents).forEach(([k, el]) => {
|
||||||
|
el.style.display = k === tab ? '' : 'none';
|
||||||
|
});
|
||||||
|
if (tab === 'events') loadEvents();
|
||||||
|
if (tab === 'stats') loadStats();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// close overlays on backdrop click
|
||||||
|
document.getElementById('articleOverlay').onclick = function(e) {
|
||||||
|
if (e.target === this) this.classList.remove('open');
|
||||||
|
};
|
||||||
|
document.getElementById('eventOverlay').onclick = function(e) {
|
||||||
|
if (e.target === this) this.classList.remove('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
loadSources();
|
||||||
|
loadArticles();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
"port": 3001,
|
"port": 3001,
|
||||||
"host": "0.0.0.0"
|
"host": "0.0.0.0"
|
||||||
},
|
},
|
||||||
|
"admin": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "changeme"
|
||||||
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"path": "./archive.sqlite"
|
"path": "./archive.sqlite"
|
||||||
},
|
},
|
||||||
|
|
@ -19,7 +23,7 @@
|
||||||
"tickers": []
|
"tickers": []
|
||||||
},
|
},
|
||||||
"openRouter": {
|
"openRouter": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"apiKey": "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",
|
"apiKey": "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",
|
||||||
"embeddingModel": "qwen/qwen3-embedding-8b"
|
"embeddingModel": "qwen/qwen3-embedding-8b"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const articleRoutes = require('./src/routes/articles');
|
||||||
const statusRoutes = require('./src/routes/status');
|
const statusRoutes = require('./src/routes/status');
|
||||||
const sourcesRoutes = require('./src/routes/sources');
|
const sourcesRoutes = require('./src/routes/sources');
|
||||||
const eventRoutes = require('./src/routes/events');
|
const eventRoutes = require('./src/routes/events');
|
||||||
|
const adminRoutes = require('./src/routes/admin');
|
||||||
const config = require('./src/config');
|
const config = require('./src/config');
|
||||||
const { startScheduler } = require('./src/scheduler');
|
const { startScheduler } = require('./src/scheduler');
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ app.register(articleRoutes);
|
||||||
app.register(statusRoutes);
|
app.register(statusRoutes);
|
||||||
app.register(sourcesRoutes);
|
app.register(sourcesRoutes);
|
||||||
app.register(eventRoutes);
|
app.register(eventRoutes);
|
||||||
|
app.register(adminRoutes);
|
||||||
|
|
||||||
app.get('/', async () => ({ ok: true }));
|
app.get('/', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
|
|
||||||
229
src/routes/admin.js
Normal file
229
src/routes/admin.js
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../db');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const adminUser = (config.admin && config.admin.username) || 'admin';
|
||||||
|
const adminPass = (config.admin && config.admin.password) || 'changeme';
|
||||||
|
|
||||||
|
function checkAuth(request, reply) {
|
||||||
|
const header = request.headers['authorization'] || '';
|
||||||
|
if (!header.startsWith('Basic ')) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"');
|
||||||
|
reply.code(401).send('Unauthorized');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||||
|
const colon = decoded.indexOf(':');
|
||||||
|
const user = decoded.slice(0, colon);
|
||||||
|
const pass = decoded.slice(colon + 1);
|
||||||
|
|
||||||
|
if (user !== adminUser || pass !== adminPass) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"');
|
||||||
|
reply.code(401).send('Unauthorized');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlPath = path.join(__dirname, '..', '..', 'admin.html');
|
||||||
|
|
||||||
|
async function adminRoutes(fastify) {
|
||||||
|
|
||||||
|
fastify.get('/admin', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
reply.type('text/html');
|
||||||
|
return fs.createReadStream(htmlPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// list articles — all of them, not just the ones with embeddings
|
||||||
|
fastify.get('/admin/api/articles', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const q = request.query || {};
|
||||||
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
||||||
|
const offset = parseInt(q.offset, 10) || 0;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (q.keyword) {
|
||||||
|
conditions.push('(title LIKE ? OR description LIKE ? OR content LIKE ?)');
|
||||||
|
const like = `%${q.keyword}%`;
|
||||||
|
params.push(like, like, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.source) {
|
||||||
|
conditions.push('source = ?');
|
||||||
|
params.push(q.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.content_status) {
|
||||||
|
if (q.content_status === 'null') {
|
||||||
|
conditions.push('content_status IS NULL');
|
||||||
|
} else {
|
||||||
|
conditions.push('content_status = ?');
|
||||||
|
params.push(q.content_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.from) {
|
||||||
|
conditions.push('ingested_at >= ?');
|
||||||
|
params.push(q.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.to) {
|
||||||
|
conditions.push('ingested_at <= ?');
|
||||||
|
params.push(q.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as n FROM articles ${where}`).get(...params).n;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, title, url, source, pub_date, ingested_at, content_status, is_index_page, has_embedding, language
|
||||||
|
FROM articles
|
||||||
|
${where}
|
||||||
|
ORDER BY ingested_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params);
|
||||||
|
|
||||||
|
return { total, rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/admin/api/articles/:id', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, title, description, content, url, normalized_title, source,
|
||||||
|
pub_date, pub_date_effective, ingested_at, content_status, content_error,
|
||||||
|
content_attempted_at, content_attempt_count, is_index_page, has_embedding, language, event_id
|
||||||
|
FROM articles WHERE id = ?
|
||||||
|
`).get(request.params.id);
|
||||||
|
|
||||||
|
if (!row) { reply.code(404); return { error: 'not found' }; }
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch('/admin/api/articles/:id', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const id = parseInt(request.params.id, 10);
|
||||||
|
const body = request.body || {};
|
||||||
|
|
||||||
|
// only allow editing these fields
|
||||||
|
const allowed = ['title', 'description', 'content', 'content_status', 'is_index_page', 'language', 'pub_date'];
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||||
|
updates.push(`${key} = ?`);
|
||||||
|
params.push(body[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'no valid fields provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
db.prepare(`UPDATE articles SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/admin/api/articles/:id', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const id = parseInt(request.params.id, 10);
|
||||||
|
|
||||||
|
// remove embeddings first so foreign key stuff doesnt bite us
|
||||||
|
db.prepare(`DELETE FROM article_embedding_store WHERE article_id = ?`).run(id);
|
||||||
|
db.prepare(`DELETE FROM article_embedding_meta WHERE article_id = ?`).run(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`DELETE FROM article_embeddings WHERE article_id = ?`).run(id);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
db.prepare(`DELETE FROM articles WHERE id = ?`).run(id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// sources list for filter dropdown
|
||||||
|
fastify.get('/admin/api/sources', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const rows = db.prepare(`SELECT DISTINCT source FROM articles ORDER BY source`).all();
|
||||||
|
return rows.map(r => r.source);
|
||||||
|
});
|
||||||
|
|
||||||
|
// events
|
||||||
|
fastify.get('/admin/api/events', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const q = request.query || {};
|
||||||
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
||||||
|
const offset = parseInt(q.offset, 10) || 0;
|
||||||
|
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT e.id, e.title, e.created_at, COUNT(a.id) as article_count
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN articles a ON a.event_id = e.id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
|
||||||
|
return { total, rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/admin/api/events/:id', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const id = parseInt(request.params.id, 10);
|
||||||
|
// detach articles from this event but dont delete the articles themselves
|
||||||
|
db.prepare(`UPDATE articles SET event_id = NULL WHERE event_id = ?`).run(id);
|
||||||
|
db.prepare(`DELETE FROM events WHERE id = ?`).run(id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch('/admin/api/events/:id', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const id = parseInt(request.params.id, 10);
|
||||||
|
const body = request.body || {};
|
||||||
|
|
||||||
|
if (!body.title) { reply.code(400); return { error: 'title is required' }; }
|
||||||
|
db.prepare(`UPDATE events SET title = ? WHERE id = ?`).run(body.title, id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// stats for dashboard header
|
||||||
|
fastify.get('/admin/api/stats', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as n FROM articles`).get().n;
|
||||||
|
const withContent = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE content IS NOT NULL AND content != ''`).get().n;
|
||||||
|
const withEmbedding = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE has_embedding = 1`).get().n;
|
||||||
|
const eventCount = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n;
|
||||||
|
|
||||||
|
const bySource = db.prepare(`
|
||||||
|
SELECT source, COUNT(*) as n FROM articles GROUP BY source ORDER BY n DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const byStatus = db.prepare(`
|
||||||
|
SELECT COALESCE(content_status, 'null') as status, COUNT(*) as n
|
||||||
|
FROM articles GROUP BY content_status ORDER BY n DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
return { total, withContent, withEmbedding, eventCount, bySource, byStatus };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = adminRoutes;
|
||||||
|
|
@ -14,54 +14,51 @@ async function eventRoutes(fastify) {
|
||||||
fastify.get('/events', async (request, reply) => {
|
fastify.get('/events', async (request, reply) => {
|
||||||
const query = request.query || {};
|
const query = request.query || {};
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
if (query.id) {
|
if (query.id) {
|
||||||
const id = Number.parseInt(query.id, 10);
|
const id = Number.parseInt(query.id, 10);
|
||||||
if (!Number.isFinite(id)) {
|
if (!Number.isFinite(id)) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'id must be a number' };
|
return { error: 'id must be a number' };
|
||||||
}
|
}
|
||||||
|
conditions.push('e.id = ?');
|
||||||
const event = db.prepare(`SELECT id, title, created_at FROM events WHERE id = ?`).get(id);
|
params.push(id);
|
||||||
if (!event) {
|
|
||||||
reply.code(404);
|
|
||||||
return { error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const articles = db.prepare(`
|
|
||||||
SELECT id, title, description, content, url, normalized_title, source, pub_date, ingested_at
|
|
||||||
FROM articles
|
|
||||||
WHERE event_id = ?
|
|
||||||
AND content IS NOT NULL AND content != ''
|
|
||||||
AND is_index_page = 0
|
|
||||||
ORDER BY pub_date_effective DESC, id DESC
|
|
||||||
`).all(id);
|
|
||||||
|
|
||||||
return { ...event, articles };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = parseLimit(query.limit);
|
const limit = parseLimit(query.limit);
|
||||||
const offset = parseOffset(query.offset);
|
const offset = parseOffset(query.offset);
|
||||||
|
|
||||||
const SORT_COLUMNS = {
|
const SORT_COLUMNS = {
|
||||||
created_at: 'e.created_at',
|
pub_date: '(SELECT MIN(a.pub_date_effective) FROM articles a WHERE a.event_id = e.id AND a.content IS NOT NULL AND a.content != \'\' AND a.is_index_page = 0)',
|
||||||
id: 'e.id',
|
id: 'e.id',
|
||||||
article_count: 'article_count',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortBy = SORT_COLUMNS[query.sort_by] || SORT_COLUMNS.created_at;
|
const sortBy = SORT_COLUMNS[query.sort_by] || SORT_COLUMNS.pub_date;
|
||||||
const order = String(query.order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
const order = String(query.order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
return db.prepare(`
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
SELECT e.id, e.title, e.created_at,
|
|
||||||
COUNT(a.id) AS article_count
|
const events = db.prepare(`
|
||||||
|
SELECT e.id, e.title,
|
||||||
|
(SELECT MIN(a.pub_date_effective) FROM articles a WHERE a.event_id = e.id AND a.content IS NOT NULL AND a.content != '' AND a.is_index_page = 0) AS pub_date
|
||||||
FROM events e
|
FROM events e
|
||||||
LEFT JOIN articles a ON a.event_id = e.id
|
${where}
|
||||||
AND a.content IS NOT NULL AND a.content != ''
|
|
||||||
AND a.is_index_page = 0
|
|
||||||
GROUP BY e.id
|
|
||||||
ORDER BY ${sortBy} ${order}, e.id ${order}
|
ORDER BY ${sortBy} ${order}, e.id ${order}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(limit, offset);
|
`).all(...params, limit, offset);
|
||||||
|
|
||||||
|
const getArticles = db.prepare(`
|
||||||
|
SELECT id, title, description, content, url, normalized_title, source, pub_date, ingested_at
|
||||||
|
FROM articles
|
||||||
|
WHERE event_id = ?
|
||||||
|
AND content IS NOT NULL AND content != ''
|
||||||
|
AND is_index_page = 0
|
||||||
|
ORDER BY pub_date_effective DESC, id DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return events.map(e => ({ ...e, articles: getArticles.all(e.id) }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
summary-prompt.md
Normal file
11
summary-prompt.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
You are a news summarizer. Extract only the facts. Write in plain declarative sentences. No hedging, no attribution ("the article says", "according to", "per the article"), no filler, no transitions.
|
||||||
|
|
||||||
|
Each bullet: one fact, one sentence, under 20 words. Lead with the subject. Use past tense.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
- Named entities (people, companies, tickers)
|
||||||
|
- Figures (prices, percentages, dates, quantities)
|
||||||
|
- Cause-effect relationships
|
||||||
|
- Direct quotes only if they carry unique signal — strip the quote wrapper, state it as fact
|
||||||
|
|
||||||
|
Maximum 10 bullets. No intro. No conclusion. No meta-commentary.
|
||||||
Loading…
Add table
Reference in a new issue