feat: add sqlite catalog web app
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
import express from 'express';
|
||||
import { CatalogDatabase } from '../database/store.js';
|
||||
import { dashboard, page, table } from './views.js';
|
||||
|
||||
export function createWebApp(databasePath: string) {
|
||||
const app = express();
|
||||
|
||||
app.get('/', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) => res.send(dashboard(db.dashboardCounts()))).catch(next);
|
||||
});
|
||||
|
||||
app.get('/links', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
page(
|
||||
'Links',
|
||||
`<h1>Links</h1>${table(db.contentLinks(), [
|
||||
['newsletter', 'Newsletter'],
|
||||
['issueDate', 'Issue Date'],
|
||||
['category', 'Category'],
|
||||
['title', 'Title'],
|
||||
['url', 'URL'],
|
||||
['description', 'Description']
|
||||
])}`
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
app.get('/sponsors', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
page(
|
||||
'Sponsored Links',
|
||||
`<h1>Sponsored Links</h1>${table(db.sponsoredLinks(), [
|
||||
['newsletter', 'Newsletter'],
|
||||
['sponsor', 'Sponsor'],
|
||||
['description', 'Description']
|
||||
])}`
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
app.get('/dead-links', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
page(
|
||||
'Dead Links',
|
||||
`<h1>Dead Links</h1>${table(db.deadLinks(), [
|
||||
['url', 'URL'],
|
||||
['status', 'Status'],
|
||||
['source', 'Source'],
|
||||
['date', 'Date']
|
||||
])}`
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
app.get('/runs', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
page(
|
||||
'Runs',
|
||||
`<h1>Runs</h1>${table(db.runs(), [
|
||||
['started_at', 'Started'],
|
||||
['mode', 'Mode'],
|
||||
['newsletters_processed', 'Newsletters'],
|
||||
['links_extracted', 'Links'],
|
||||
['sponsors', 'Sponsors'],
|
||||
['dead_links', 'Dead Links'],
|
||||
['errors', 'Errors']
|
||||
])}`
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
app.use(
|
||||
(error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(error);
|
||||
res.status(500).send(page('Error', '<h1>Error</h1><p>Something went wrong.</p>'));
|
||||
}
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function withDatabase(
|
||||
databasePath: string,
|
||||
callback: (database: CatalogDatabase) => void
|
||||
): Promise<void> {
|
||||
const db = new CatalogDatabase(databasePath);
|
||||
try {
|
||||
db.migrate();
|
||||
callback(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function page(title: string, body: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2rem; color: #202124; }
|
||||
nav a { margin-right: 1rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
|
||||
th, td { border: 1px solid #ddd; padding: 0.45rem; text-align: left; vertical-align: top; }
|
||||
th { background: #f5f5f5; }
|
||||
.cards { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.card { border: 1px solid #ddd; padding: 1rem; min-width: 10rem; }
|
||||
.muted { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/links">Links</a>
|
||||
<a href="/sponsors">Sponsored Links</a>
|
||||
<a href="/dead-links">Dead Links</a>
|
||||
<a href="/runs">Runs</a>
|
||||
</nav>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function table(rows: Record<string, unknown>[], columns: Array<[string, string]>): string {
|
||||
if (rows.length === 0) {
|
||||
return '<p class="muted">No rows yet.</p>';
|
||||
}
|
||||
return `<table><thead><tr>${columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead><tbody>${rows
|
||||
.map((row) => `<tr>${columns.map(([key]) => `<td>${escapeHtml(row[key])}</td>`).join('')}</tr>`)
|
||||
.join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
export function dashboard(counts: Record<string, number>): string {
|
||||
return page(
|
||||
'Newsletter Link Catalog',
|
||||
`<h1>Newsletter Link Catalog</h1>
|
||||
<section class="cards">
|
||||
${Object.entries(counts)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<div class="card"><strong>${escapeHtml(value)}</strong><br>${escapeHtml(key)}</div>`
|
||||
)
|
||||
.join('')}
|
||||
</section>`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user