add admin interface with article and event management features

This commit is contained in:
ImBenji
2026-04-21 21:57:00 +01:00
parent 81bcf40a8d
commit 715172596f
6 changed files with 964 additions and 29 deletions
+692
View 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, '&quot;')})">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>