Duriin-API/admin.html

692 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>