feat: implement newsletter browser UI and enhance link filtering functionality
This commit is contained in:
@@ -99,7 +99,10 @@ Start the local read-only web app with:
|
||||
nlc serve --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
The first web UI is intentionally functional: dashboard, links, sponsored links, dead links, and runs.
|
||||
The web UI is intentionally functional and read-only. It includes dashboard totals, a two-pane
|
||||
newsletter browser, all links, sponsored links, dead links, and run history. The newsletter browser
|
||||
opens on the most recent issue to keep the default view focused; switch to **All Issues** when you
|
||||
want the full link history for the selected newsletter.
|
||||
|
||||
## Build and Distribution
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ export interface CatalogRunPayload extends CatalogPayload {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface NewsletterSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
latestIssueDate: string;
|
||||
issueCount: number;
|
||||
linkCount: number;
|
||||
sponsorCount: number;
|
||||
}
|
||||
|
||||
export interface NewsletterLinkQuery {
|
||||
scope?: 'latest' | 'all';
|
||||
search?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class CatalogDatabase {
|
||||
private readonly db: import('node:sqlite').DatabaseSync;
|
||||
|
||||
@@ -97,6 +112,91 @@ export class CatalogDatabase {
|
||||
.all();
|
||||
}
|
||||
|
||||
public newsletterSummaries(search = ''): NewsletterSummary[] {
|
||||
const searchPattern = `%${search}%`;
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
n.id,
|
||||
n.name,
|
||||
COALESCE(MAX(i.issue_date), '') AS latestIssueDate,
|
||||
COUNT(DISTINCT i.id) AS issueCount,
|
||||
COUNT(DISTINCT o.id) AS linkCount,
|
||||
COUNT(DISTINCT s.id) AS sponsorCount
|
||||
FROM newsletters n
|
||||
LEFT JOIN issues i ON i.newsletter_id = n.id
|
||||
LEFT JOIN link_occurrences o ON o.issue_id = i.id
|
||||
LEFT JOIN sponsors s ON s.newsletter = n.name
|
||||
WHERE n.name LIKE ?
|
||||
GROUP BY n.id, n.name
|
||||
ORDER BY n.name`
|
||||
)
|
||||
.all(searchPattern) as unknown as NewsletterSummary[];
|
||||
}
|
||||
|
||||
public newsletterById(id: number): NewsletterSummary | undefined {
|
||||
return this.newsletterSummaries().find((newsletter) => newsletter.id === id);
|
||||
}
|
||||
|
||||
public categoriesForNewsletter(newsletterId: number): string[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT o.category
|
||||
FROM link_occurrences o
|
||||
JOIN issues i ON i.id = o.issue_id
|
||||
WHERE i.newsletter_id = ? AND o.category <> ''
|
||||
ORDER BY o.category`
|
||||
)
|
||||
.all(newsletterId)
|
||||
.map((row: any) => row.category);
|
||||
}
|
||||
|
||||
public newsletterLinks(newsletterId: number, query: NewsletterLinkQuery = {}): any[] {
|
||||
const scope = query.scope ?? 'latest';
|
||||
const filters = ['i.newsletter_id = ?'];
|
||||
const params: Array<string | number> = [newsletterId];
|
||||
|
||||
if (scope === 'latest') {
|
||||
filters.push('i.issue_date = (SELECT MAX(issue_date) FROM issues WHERE newsletter_id = ?)');
|
||||
params.push(newsletterId);
|
||||
}
|
||||
if (query.search) {
|
||||
filters.push('(o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ?)');
|
||||
const search = `%${query.search}%`;
|
||||
params.push(search, search, search);
|
||||
}
|
||||
if (query.category) {
|
||||
filters.push('o.category = ?');
|
||||
params.push(query.category);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT i.issue_date AS issueDate, o.category, o.title, o.description, l.url, o.also_in AS alsoIn
|
||||
FROM link_occurrences o
|
||||
JOIN issues i ON i.id = o.issue_id
|
||||
JOIN links l ON l.id = o.link_id
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY i.issue_date DESC, o.title`
|
||||
)
|
||||
.all(...params);
|
||||
}
|
||||
|
||||
public allLinks(search = ''): any[] {
|
||||
const searchPattern = `%${search}%`;
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT n.name AS newsletter, i.issue_date AS issueDate, o.category, o.title, l.url, o.description
|
||||
FROM link_occurrences o
|
||||
JOIN issues i ON i.id = o.issue_id
|
||||
JOIN newsletters n ON n.id = i.newsletter_id
|
||||
JOIN links l ON l.id = o.link_id
|
||||
WHERE o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ? OR n.name LIKE ?
|
||||
ORDER BY i.issue_date DESC, n.name, o.title`
|
||||
)
|
||||
.all(searchPattern, searchPattern, searchPattern, searchPattern);
|
||||
}
|
||||
|
||||
public sponsoredLinks(): any[] {
|
||||
return this.db
|
||||
.prepare('SELECT newsletter, sponsor, description FROM sponsors ORDER BY newsletter, sponsor')
|
||||
|
||||
+64
-7
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import { CatalogDatabase } from '../database/store.js';
|
||||
import { dashboard, page, table } from './views.js';
|
||||
import { dashboard, newsletterBrowser, page, table } from './views.js';
|
||||
|
||||
export function createWebApp(databasePath: string) {
|
||||
const app = express();
|
||||
@@ -9,24 +9,65 @@ export function createWebApp(databasePath: string) {
|
||||
withDatabase(databasePath, (db) => res.send(dashboard(db.dashboardCounts()))).catch(next);
|
||||
});
|
||||
|
||||
app.get('/links', (_req, res, next) => {
|
||||
app.get('/links', (req, res, next) => {
|
||||
const search = stringQuery(req.query.q);
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
page(
|
||||
'Links',
|
||||
`<h1>Links</h1>${table(db.contentLinks(), [
|
||||
`<h1>All Links</h1>
|
||||
<form class="toolbar" method="get" action="/links">
|
||||
<label>Search links
|
||||
<input type="search" name="q" value="${escapeHtml(search)}">
|
||||
</label>
|
||||
<button class="button" type="submit">Apply</button>
|
||||
</form>
|
||||
${table(db.allLinks(search), [
|
||||
['newsletter', 'Newsletter'],
|
||||
['issueDate', 'Issue Date'],
|
||||
['category', 'Category'],
|
||||
['title', 'Title'],
|
||||
['url', 'URL'],
|
||||
['description', 'Description']
|
||||
])}`
|
||||
])}`,
|
||||
'links'
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
app.get('/newsletters', (req, res, next) => {
|
||||
withDatabase(databasePath, (db) => {
|
||||
const scope = req.query.scope === 'all' ? 'all' : 'latest';
|
||||
const linkSearch = stringQuery(req.query.q);
|
||||
const category = stringQuery(req.query.category);
|
||||
const newsletters = db.newsletterSummaries();
|
||||
const requestedNewsletterId = Number(req.query.newsletter);
|
||||
const selected =
|
||||
newsletters.find((newsletter) => newsletter.id === requestedNewsletterId) ?? newsletters[0];
|
||||
|
||||
res.send(
|
||||
newsletterBrowser({
|
||||
newsletters,
|
||||
selected,
|
||||
links: selected
|
||||
? db.newsletterLinks(selected.id, {
|
||||
category,
|
||||
scope,
|
||||
search: linkSearch
|
||||
})
|
||||
: [],
|
||||
categories: selected ? db.categoriesForNewsletter(selected.id) : [],
|
||||
filters: {
|
||||
category,
|
||||
linkSearch,
|
||||
scope
|
||||
}
|
||||
})
|
||||
);
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
app.get('/sponsors', (_req, res, next) => {
|
||||
withDatabase(databasePath, (db) =>
|
||||
res.send(
|
||||
@@ -36,7 +77,8 @@ export function createWebApp(databasePath: string) {
|
||||
['newsletter', 'Newsletter'],
|
||||
['sponsor', 'Sponsor'],
|
||||
['description', 'Description']
|
||||
])}`
|
||||
])}`,
|
||||
'sponsors'
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
@@ -52,7 +94,8 @@ export function createWebApp(databasePath: string) {
|
||||
['status', 'Status'],
|
||||
['source', 'Source'],
|
||||
['date', 'Date']
|
||||
])}`
|
||||
])}`,
|
||||
'dead-links'
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
@@ -71,7 +114,8 @@ export function createWebApp(databasePath: string) {
|
||||
['sponsors', 'Sponsors'],
|
||||
['dead_links', 'Dead Links'],
|
||||
['errors', 'Errors']
|
||||
])}`
|
||||
])}`,
|
||||
'runs'
|
||||
)
|
||||
)
|
||||
).catch(next);
|
||||
@@ -87,6 +131,19 @@ export function createWebApp(databasePath: string) {
|
||||
return app;
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function stringQuery(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
async function withDatabase(
|
||||
databasePath: string,
|
||||
callback: (database: CatalogDatabase) => void
|
||||
|
||||
+412
-19
@@ -1,3 +1,19 @@
|
||||
import { NewsletterSummary } from '../database/store.js';
|
||||
|
||||
type ActivePage = 'dashboard' | 'newsletters' | 'links' | 'sponsors' | 'dead-links' | 'runs';
|
||||
|
||||
interface NewsletterBrowserModel {
|
||||
newsletters: NewsletterSummary[];
|
||||
selected?: NewsletterSummary;
|
||||
links: Record<string, unknown>[];
|
||||
categories: string[];
|
||||
filters: {
|
||||
category: string;
|
||||
linkSearch: string;
|
||||
scope: 'latest' | 'all';
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -7,7 +23,27 @@ function escapeHtml(value: unknown): string {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function page(title: string, body: string): string {
|
||||
function escapeAttr(value: unknown): string {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function pageUrl(path: string, params: Record<string, string | number | undefined>): string {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const serialized = query.toString();
|
||||
return serialized ? `${path}?${serialized}` : path;
|
||||
}
|
||||
|
||||
function navLink(href: string, label: string, pageName: ActivePage, active: ActivePage): string {
|
||||
const current = pageName === active ? ' aria-current="page"' : '';
|
||||
return `<a href="${href}"${current}>${escapeHtml(label)}</a>`;
|
||||
}
|
||||
|
||||
export function page(title: string, body: string, active: ActivePage = 'dashboard'): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -15,25 +51,247 @@ export function page(title: string, body: string): string {
|
||||
<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; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101214;
|
||||
--panel: #171a1f;
|
||||
--panel-strong: #20242b;
|
||||
--text: #eef1f4;
|
||||
--muted: #a5adb7;
|
||||
--line: #303640;
|
||||
--accent: #62b3ff;
|
||||
--accent-soft: rgba(98, 179, 255, 0.14);
|
||||
--danger: #ff7d7d;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--panel-strong: #eef1f5;
|
||||
--text: #1d232b;
|
||||
--muted: #5f6b78;
|
||||
--line: #d9dee5;
|
||||
--accent: #0369a1;
|
||||
--accent-soft: rgba(3, 105, 161, 0.1);
|
||||
--danger: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 3.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.brand { color: var(--text); font-size: 1rem; font-weight: 700; }
|
||||
.global-nav { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.global-nav a,
|
||||
.theme-toggle {
|
||||
min-height: 2rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.global-nav a[aria-current="page"] {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
.theme-toggle { cursor: pointer; border-color: var(--line); }
|
||||
main { padding: 1.25rem; }
|
||||
h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: 0; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
h2 { font-size: 1.05rem; }
|
||||
p { margin: 0; }
|
||||
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; }
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { color: var(--muted); font-size: 0.78rem; font-weight: 700; text-transform: uppercase; }
|
||||
input, select {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
font: inherit;
|
||||
}
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; }
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: var(--panel);
|
||||
}
|
||||
.metric { display: block; font-size: 1.45rem; font-weight: 750; }
|
||||
.muted { color: var(--muted); }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; margin: 1rem 0; }
|
||||
.toolbar label { display: grid; gap: 0.25rem; min-width: 12rem; color: var(--muted); font-size: 0.83rem; }
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--text);
|
||||
background: var(--panel-strong);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 21rem) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: calc(100vh - 6.25rem);
|
||||
}
|
||||
.sidebar,
|
||||
.detail {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.sidebar { padding: 0.85rem; }
|
||||
.sidebar-header { display: grid; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
.newsletter-list { display: grid; gap: 0.45rem; }
|
||||
.newsletter-item {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.newsletter-item[aria-current="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.newsletter-item:hover { border-color: var(--line); text-decoration: none; }
|
||||
.newsletter-title { font-weight: 700; }
|
||||
.newsletter-meta { display: flex; flex-wrap: wrap; gap: 0.45rem; color: var(--muted); font-size: 0.82rem; }
|
||||
.detail { overflow: hidden; }
|
||||
.detail-header {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
.detail-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.counts { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.count-pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
width: fit-content;
|
||||
}
|
||||
.segmented a {
|
||||
min-width: 6.5rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.segmented a[aria-current="true"] {
|
||||
color: var(--text);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.detail-body { padding: 1rem; overflow-x: auto; }
|
||||
.link-title { font-weight: 700; }
|
||||
.description { max-width: 36rem; color: var(--muted); }
|
||||
.url-cell { max-width: 20rem; overflow-wrap: anywhere; }
|
||||
.empty {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.topbar { align-items: flex-start; flex-direction: column; }
|
||||
main { padding: 0.75rem; }
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { max-height: 18rem; overflow: auto; }
|
||||
.toolbar label { min-width: 100%; }
|
||||
}
|
||||
</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}
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">Newsletter Link Catalog</a>
|
||||
<nav class="global-nav" aria-label="Primary">
|
||||
${navLink('/', 'Dashboard', 'dashboard', active)}
|
||||
${navLink('/newsletters', 'Newsletters', 'newsletters', active)}
|
||||
${navLink('/links', 'All Links', 'links', active)}
|
||||
${navLink('/sponsors', 'Sponsored Links', 'sponsors', active)}
|
||||
${navLink('/dead-links', 'Dead Links', 'dead-links', active)}
|
||||
${navLink('/runs', 'Runs', 'runs', active)}
|
||||
</nav>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle>Theme</button>
|
||||
</header>
|
||||
<main>${body}</main>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const savedTheme = localStorage.getItem('nlc-theme');
|
||||
if (savedTheme) root.dataset.theme = savedTheme;
|
||||
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
|
||||
const next = root.dataset.theme === 'light' ? '' : 'light';
|
||||
if (next) {
|
||||
root.dataset.theme = next;
|
||||
localStorage.setItem('nlc-theme', next);
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
localStorage.removeItem('nlc-theme');
|
||||
}
|
||||
});
|
||||
const search = document.querySelector('[data-newsletter-search]');
|
||||
search?.addEventListener('input', () => {
|
||||
const term = search.value.toLowerCase();
|
||||
document.querySelectorAll('[data-newsletter-name]').forEach((item) => {
|
||||
item.hidden = !item.dataset.newsletterName.includes(term);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -51,13 +309,148 @@ export function dashboard(counts: Record<string, number>): string {
|
||||
return page(
|
||||
'Newsletter Link Catalog',
|
||||
`<h1>Newsletter Link Catalog</h1>
|
||||
<section class="cards">
|
||||
<section class="cards" aria-label="Catalog totals">
|
||||
${Object.entries(counts)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<div class="card"><strong>${escapeHtml(value)}</strong><br>${escapeHtml(key)}</div>`
|
||||
`<div class="card"><span class="metric">${escapeHtml(value)}</span><span class="muted">${escapeHtml(key)}</span></div>`
|
||||
)
|
||||
.join('')}
|
||||
</section>`
|
||||
</section>`,
|
||||
'dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
export function newsletterBrowser(model: NewsletterBrowserModel): string {
|
||||
const { selected } = model;
|
||||
const selectedId = selected?.id ?? '';
|
||||
return page(
|
||||
'Newsletters',
|
||||
`<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Newsletters">
|
||||
<div class="sidebar-header">
|
||||
<h1>Newsletters</h1>
|
||||
<input type="search" placeholder="Search newsletters" aria-label="Search newsletters" data-newsletter-search>
|
||||
</div>
|
||||
<div class="newsletter-list">
|
||||
${model.newsletters.length === 0 ? '<p class="muted">No newsletters yet.</p>' : ''}
|
||||
${model.newsletters
|
||||
.map((newsletter) =>
|
||||
newsletterListItem(newsletter, model.filters, newsletter.id === selected?.id)
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</aside>
|
||||
<section class="detail" aria-label="Selected newsletter links">
|
||||
${selected ? selectedNewsletterView(model, selectedId) : '<div class="detail-body"><p class="empty">No newsletter selected.</p></div>'}
|
||||
</section>
|
||||
</div>`,
|
||||
'newsletters'
|
||||
);
|
||||
}
|
||||
|
||||
function newsletterListItem(
|
||||
newsletter: NewsletterSummary,
|
||||
filters: NewsletterBrowserModel['filters'],
|
||||
isSelected: boolean
|
||||
): string {
|
||||
const href = pageUrl('/newsletters', {
|
||||
newsletter: newsletter.id,
|
||||
scope: filters.scope,
|
||||
q: filters.linkSearch,
|
||||
category: filters.category
|
||||
});
|
||||
return `<a class="newsletter-item" href="${escapeAttr(href)}" data-newsletter-name="${escapeAttr(newsletter.name.toLowerCase())}" aria-current="${isSelected}">
|
||||
<span class="newsletter-title">${escapeHtml(newsletter.name)}</span>
|
||||
<span class="newsletter-meta">
|
||||
<span>${escapeHtml(newsletter.latestIssueDate || 'No issues')}</span>
|
||||
<span>${escapeHtml(newsletter.issueCount)} issues</span>
|
||||
<span>${escapeHtml(newsletter.linkCount)} links</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function selectedNewsletterView(
|
||||
model: NewsletterBrowserModel,
|
||||
selectedId: number | string
|
||||
): string {
|
||||
const { filters, selected } = model;
|
||||
const latestHref = pageUrl('/newsletters', {
|
||||
newsletter: selectedId,
|
||||
scope: 'latest',
|
||||
q: filters.linkSearch,
|
||||
category: filters.category
|
||||
});
|
||||
const allHref = pageUrl('/newsletters', {
|
||||
newsletter: selectedId,
|
||||
scope: 'all',
|
||||
q: filters.linkSearch,
|
||||
category: filters.category
|
||||
});
|
||||
return `<div class="detail-header">
|
||||
<div class="detail-title-row">
|
||||
<div>
|
||||
<h1>${escapeHtml(selected?.name)}</h1>
|
||||
<p class="muted">Latest issue ${escapeHtml(selected?.latestIssueDate || 'not available')}</p>
|
||||
</div>
|
||||
<div class="counts" aria-label="Newsletter counts">
|
||||
<span class="count-pill">${escapeHtml(selected?.issueCount)} issues</span>
|
||||
<span class="count-pill">${escapeHtml(selected?.linkCount)} links</span>
|
||||
<span class="count-pill">${escapeHtml(selected?.sponsorCount)} sponsored</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="segmented" aria-label="Issue scope">
|
||||
<a href="${escapeAttr(latestHref)}" aria-current="${filters.scope === 'latest'}">Latest Issue</a>
|
||||
<a href="${escapeAttr(allHref)}" aria-current="${filters.scope === 'all'}">All Issues</a>
|
||||
</div>
|
||||
<form class="toolbar" method="get" action="/newsletters">
|
||||
<input type="hidden" name="newsletter" value="${escapeAttr(selectedId)}">
|
||||
<input type="hidden" name="scope" value="${escapeAttr(filters.scope)}">
|
||||
<label>Search links
|
||||
<input type="search" name="q" value="${escapeAttr(filters.linkSearch)}">
|
||||
</label>
|
||||
<label>Category
|
||||
<select name="category">
|
||||
<option value="">All categories</option>
|
||||
${model.categories
|
||||
.map(
|
||||
(category) =>
|
||||
`<option value="${escapeAttr(category)}"${category === filters.category ? ' selected' : ''}>${escapeHtml(category)}</option>`
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
${linksTable(model.links, filters.scope)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function linksTable(rows: Record<string, unknown>[], scope: 'latest' | 'all'): string {
|
||||
if (rows.length === 0) {
|
||||
return '<p class="empty">No links match the current filters.</p>';
|
||||
}
|
||||
const issueHeader = scope === 'all' ? '<th>Issue Date</th>' : '';
|
||||
return `<table>
|
||||
<thead>
|
||||
<tr>${issueHeader}<th>Title</th><th>Category</th><th>Description</th><th>URL</th><th>Also In</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((row) => linkRow(row, scope)).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function linkRow(row: Record<string, unknown>, scope: 'latest' | 'all'): string {
|
||||
const issueDate = scope === 'all' ? `<td>${escapeHtml(row.issueDate)}</td>` : '';
|
||||
return `<tr>
|
||||
${issueDate}
|
||||
<td><a class="link-title" href="${escapeAttr(row.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(row.title || row.url)}</a></td>
|
||||
<td>${escapeHtml(row.category)}</td>
|
||||
<td class="description">${escapeHtml(row.description)}</td>
|
||||
<td class="url-cell"><a href="${escapeAttr(row.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(row.url)}</a></td>
|
||||
<td>${escapeHtml(row.alsoIn)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
+88
-2
@@ -33,12 +33,22 @@ function fixtureDatabase(): string {
|
||||
const db = new CatalogDatabase(path);
|
||||
db.saveCatalogRun({
|
||||
mode: 'test',
|
||||
newslettersProcessed: 1,
|
||||
linksExtracted: 1,
|
||||
newslettersProcessed: 2,
|
||||
linksExtracted: 3,
|
||||
sponsorCount: 1,
|
||||
deadLinkCount: 1,
|
||||
errors: 0,
|
||||
rows: [
|
||||
{
|
||||
'Issue Date': '2026-05-10',
|
||||
Category: 'SQLite',
|
||||
'Link URL': 'https://sqlite.example/old',
|
||||
Title: 'Older SQLite Post',
|
||||
Description: 'An older database post',
|
||||
'Page Title + Meta': '',
|
||||
'Source Newsletter': 'DB Weekly',
|
||||
'Also In': ''
|
||||
},
|
||||
{
|
||||
'Issue Date': '2026-05-17',
|
||||
Category: 'SQLite',
|
||||
@@ -48,6 +58,16 @@ function fixtureDatabase(): string {
|
||||
'Page Title + Meta': '',
|
||||
'Source Newsletter': 'DB Weekly',
|
||||
'Also In': ''
|
||||
},
|
||||
{
|
||||
'Issue Date': '2026-05-16',
|
||||
Category: 'JavaScript',
|
||||
'Link URL': 'https://js.example',
|
||||
Title: 'JS Post',
|
||||
Description: 'A JavaScript post',
|
||||
'Page Title + Meta': '',
|
||||
'Source Newsletter': 'JS Weekly',
|
||||
'Also In': ''
|
||||
}
|
||||
],
|
||||
sponsors: [
|
||||
@@ -67,13 +87,79 @@ function fixtureDatabase(): string {
|
||||
}
|
||||
|
||||
describe('web app', () => {
|
||||
it('provides newsletter summaries and latest/all issue link scopes', () => {
|
||||
const path = fixtureDatabase();
|
||||
const db = new CatalogDatabase(path);
|
||||
try {
|
||||
const summaries = db.newsletterSummaries();
|
||||
expect(
|
||||
summaries.map(({ name, latestIssueDate, issueCount, linkCount, sponsorCount }) => ({
|
||||
name,
|
||||
latestIssueDate,
|
||||
issueCount,
|
||||
linkCount,
|
||||
sponsorCount
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
name: 'DB Weekly',
|
||||
latestIssueDate: '2026-05-17',
|
||||
issueCount: 2,
|
||||
linkCount: 2,
|
||||
sponsorCount: 1
|
||||
},
|
||||
{
|
||||
name: 'JS Weekly',
|
||||
latestIssueDate: '2026-05-16',
|
||||
issueCount: 1,
|
||||
linkCount: 1,
|
||||
sponsorCount: 0
|
||||
}
|
||||
]);
|
||||
|
||||
const dbWeekly = summaries.find((newsletter) => newsletter.name === 'DB Weekly');
|
||||
expect(
|
||||
db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'latest' }).map((link) => link.title)
|
||||
).toEqual(['SQLite Post']);
|
||||
expect(
|
||||
db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'all' }).map((link) => link.title)
|
||||
).toEqual(['SQLite Post', 'Older SQLite Post']);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders dashboard and catalog pages from SQLite', async () => {
|
||||
const path = fixtureDatabase();
|
||||
|
||||
await expect(withServer(path, '/')).resolves.toContain('Newsletter Link Catalog');
|
||||
await expect(withServer(path, '/links')).resolves.toContain('SQLite Post');
|
||||
await expect(withServer(path, '/links?q=JavaScript')).resolves.toContain('JS Post');
|
||||
await expect(withServer(path, '/links?q=JavaScript')).resolves.not.toContain('SQLite Post');
|
||||
await expect(withServer(path, '/sponsors')).resolves.toContain('Acme');
|
||||
await expect(withServer(path, '/dead-links')).resolves.toContain('https://dead.example');
|
||||
await expect(withServer(path, '/runs')).resolves.toContain('test');
|
||||
});
|
||||
|
||||
it('renders the two-pane newsletter browser with latest issue by default and all issue mode', async () => {
|
||||
const path = fixtureDatabase();
|
||||
|
||||
const latest = await withServer(path, '/newsletters');
|
||||
expect(latest).toContain('class="app-shell"');
|
||||
expect(latest).toContain('Search newsletters');
|
||||
expect(latest).toContain('aria-current="true"');
|
||||
expect(latest).toContain('Latest Issue');
|
||||
expect(latest).toContain('All Issues');
|
||||
expect(latest).toContain('SQLite Post');
|
||||
expect(latest).not.toContain('Older SQLite Post');
|
||||
expect(latest).toContain('target="_blank"');
|
||||
expect(latest).toContain('rel="noopener noreferrer"');
|
||||
|
||||
const allIssues = await withServer(path, '/newsletters?scope=all');
|
||||
expect(allIssues).toContain('SQLite Post');
|
||||
expect(allIssues).toContain('Older SQLite Post');
|
||||
expect(allIssues).toContain('2026-05-10');
|
||||
expect(allIssues).toContain('name="category"');
|
||||
expect(allIssues).toContain('name="q"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user