Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f347fd1b | |||
| f4ff04f92b |
@@ -99,7 +99,10 @@ Start the local read-only web app with:
|
|||||||
nlc serve --host 127.0.0.1 --port 3000
|
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
|
## Build and Distribution
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Newsletter UI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the rough web views with a functional two-pane newsletter browser that defaults to the latest issue and can switch to all issues.
|
||||||
|
|
||||||
|
**Architecture:** Keep the current Express/server-rendered UI. Add focused SQLite read methods to `CatalogDatabase`, render reusable layout/navigation/table helpers in `src/web/views.ts`, and wire new routes in `src/web/app.ts` for dashboard, newsletter browser, all links, sponsors, dead links, and runs. Use query parameters for selected newsletter, issue scope, link search, and category filters.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Express, Node `node:sqlite`, server-rendered HTML/CSS, Vitest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Database Read Models
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/database/store.ts`
|
||||||
|
- Test: `tests/web.test.ts`
|
||||||
|
|
||||||
|
- [ ] Write failing tests that a fixture database can return newsletter summaries with issue/link/sponsor counts.
|
||||||
|
- [ ] Write failing tests that selected newsletter links default to the latest issue and can include all issues.
|
||||||
|
- [ ] Add `newsletterSummaries()`, `newsletterById()`, `newsletterLinks()`, `categoriesForNewsletter()`, and `allLinks()` read methods.
|
||||||
|
- [ ] Run `npm test -- tests/web.test.ts`.
|
||||||
|
|
||||||
|
### Task 2: Two-Pane Newsletter Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/web/views.ts`
|
||||||
|
- Modify: `src/web/app.ts`
|
||||||
|
- Test: `tests/web.test.ts`
|
||||||
|
|
||||||
|
- [ ] Write failing tests that `/newsletters` renders a two-pane layout, newsletter search input, selected state, latest/all issue controls, category filter, and external link attributes.
|
||||||
|
- [ ] Implement reusable page layout with dark default styling, light mode toggle, global nav, responsive two-pane layout, and empty states.
|
||||||
|
- [ ] Implement `/newsletters` with query params: `newsletter`, `scope`, `q`, `category`.
|
||||||
|
- [ ] Run `npm test -- tests/web.test.ts`.
|
||||||
|
|
||||||
|
### Task 3: Global Views and Smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/web/app.ts`
|
||||||
|
- Modify: `src/web/views.ts`
|
||||||
|
- Modify: `scripts/smoke.mjs`
|
||||||
|
- Test: `tests/web.test.ts`
|
||||||
|
|
||||||
|
- [ ] Write failing tests that dashboard, all links, sponsored links, dead links, and runs use the new layout.
|
||||||
|
- [ ] Update `/`, `/links`, `/sponsors`, `/dead-links`, and `/runs` to use the shared layout and table helpers.
|
||||||
|
- [ ] Keep `nlc serve --help` in smoke validation.
|
||||||
|
- [ ] Run `npm test -- tests/web.test.ts`.
|
||||||
|
|
||||||
|
### Task 4: Docs and Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Add: `notes/UI Spec.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] Track the approved UI spec.
|
||||||
|
- [ ] Document `nlc serve` and the newsletter browser behavior.
|
||||||
|
- [ ] Run `npm run lint`.
|
||||||
|
- [ ] Run `npm run format:check`.
|
||||||
|
- [ ] Run `npm run typecheck`.
|
||||||
|
- [ ] Run `npm test`.
|
||||||
|
- [ ] Run `npm run build`.
|
||||||
|
- [ ] Run `npm run smoke`.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
The plan covers the approved UI spec: two-pane layout, newsletter navigation, latest issue default, all-issues option, global navigation, search/filter controls, dark default, light option, mobile-friendly responsive behavior, and safe external links. It avoids a frontend build stack and keeps the first implementation functional.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Newsletter Link Catalog UI Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create a clean, functional web UI for browsing the SQLite-backed newsletter catalog. The UI should feel like a focused database browser: fast to scan, easy to filter, and useful before visual polish.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Use a two-pane catalog layout.
|
||||||
|
|
||||||
|
### Left Pane: Newsletter Navigation
|
||||||
|
|
||||||
|
- Narrow column on desktop.
|
||||||
|
- Contains a newsletter search field at the top.
|
||||||
|
- Shows a scrollable list of newsletters below the search.
|
||||||
|
- Each newsletter item should show:
|
||||||
|
- Newsletter name.
|
||||||
|
- Optional short description or source email when available.
|
||||||
|
- Compact counts such as issues, links, and sponsored links.
|
||||||
|
- The currently selected newsletter should have a clear selected state.
|
||||||
|
|
||||||
|
### Right Pane: Newsletter Detail
|
||||||
|
|
||||||
|
- Shows details for the selected newsletter.
|
||||||
|
- Defaults to the most recent issue only to reduce cognitive load.
|
||||||
|
- Header should show:
|
||||||
|
- Newsletter name.
|
||||||
|
- Optional description/source email.
|
||||||
|
- Latest issue date.
|
||||||
|
- Counts for the displayed issue.
|
||||||
|
- Links should be displayed in a scannable table or grouped list.
|
||||||
|
- Link rows should include:
|
||||||
|
- Title.
|
||||||
|
- Category.
|
||||||
|
- Description.
|
||||||
|
- URL.
|
||||||
|
- Also In, when available.
|
||||||
|
- Links should be clickable and open in a new tab using `target="_blank"` and `rel="noopener noreferrer"`.
|
||||||
|
|
||||||
|
## Issue Scope Controls
|
||||||
|
|
||||||
|
The right pane defaults to **Latest Issue**.
|
||||||
|
|
||||||
|
Provide a clear control to switch to **All Issues** for the selected newsletter. In All Issues mode:
|
||||||
|
|
||||||
|
- Show links from every issue for the selected newsletter.
|
||||||
|
- Include issue date on each row.
|
||||||
|
- Keep the same search/filter controls.
|
||||||
|
- Make it easy to return to Latest Issue mode.
|
||||||
|
|
||||||
|
## Global Navigation
|
||||||
|
|
||||||
|
Provide lightweight navigation for:
|
||||||
|
|
||||||
|
- Dashboard.
|
||||||
|
- Newsletters.
|
||||||
|
- All Links.
|
||||||
|
- Sponsored Links.
|
||||||
|
- Dead Links.
|
||||||
|
- Runs.
|
||||||
|
|
||||||
|
The Newsletter view should be the primary working screen.
|
||||||
|
|
||||||
|
## Search and Filters
|
||||||
|
|
||||||
|
- Newsletter search filters the left pane immediately.
|
||||||
|
- Link search filters the right pane within the selected newsletter and current issue scope.
|
||||||
|
- Category filtering should be available in the right pane.
|
||||||
|
- Date filtering is only needed in All Issues mode.
|
||||||
|
- Empty states should explain what is missing:
|
||||||
|
- No newsletters imported yet.
|
||||||
|
- No links for the selected newsletter.
|
||||||
|
- No matches for the current search/filter.
|
||||||
|
|
||||||
|
## Theme and Responsiveness
|
||||||
|
|
||||||
|
- Dark mode should be the default visual direction.
|
||||||
|
- Respect system preference where practical.
|
||||||
|
- Provide a light mode option.
|
||||||
|
- Mobile layout should collapse the left pane into a drawer, dropdown, or stacked selector.
|
||||||
|
- The UI should remain usable on small screens without horizontal scrolling for core actions.
|
||||||
|
|
||||||
|
## Visual Style
|
||||||
|
|
||||||
|
- Clean, restrained, and operational.
|
||||||
|
- Avoid marketing-style hero sections.
|
||||||
|
- Prioritize readable tables/lists, clear selected states, and compact metadata.
|
||||||
|
- UI polish can remain minimal for the first implementation, but spacing, contrast, and text wrapping should be professional.
|
||||||
@@ -16,6 +16,21 @@ export interface CatalogRunPayload extends CatalogPayload {
|
|||||||
errors: number;
|
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 {
|
export class CatalogDatabase {
|
||||||
private readonly db: import('node:sqlite').DatabaseSync;
|
private readonly db: import('node:sqlite').DatabaseSync;
|
||||||
|
|
||||||
@@ -97,6 +112,91 @@ export class CatalogDatabase {
|
|||||||
.all();
|
.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[] {
|
public sponsoredLinks(): any[] {
|
||||||
return this.db
|
return this.db
|
||||||
.prepare('SELECT newsletter, sponsor, description FROM sponsors ORDER BY newsletter, sponsor')
|
.prepare('SELECT newsletter, sponsor, description FROM sponsors ORDER BY newsletter, sponsor')
|
||||||
|
|||||||
+64
-7
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { CatalogDatabase } from '../database/store.js';
|
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) {
|
export function createWebApp(databasePath: string) {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -9,24 +9,65 @@ export function createWebApp(databasePath: string) {
|
|||||||
withDatabase(databasePath, (db) => res.send(dashboard(db.dashboardCounts()))).catch(next);
|
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) =>
|
withDatabase(databasePath, (db) =>
|
||||||
res.send(
|
res.send(
|
||||||
page(
|
page(
|
||||||
'Links',
|
'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'],
|
['newsletter', 'Newsletter'],
|
||||||
['issueDate', 'Issue Date'],
|
['issueDate', 'Issue Date'],
|
||||||
['category', 'Category'],
|
['category', 'Category'],
|
||||||
['title', 'Title'],
|
['title', 'Title'],
|
||||||
['url', 'URL'],
|
['url', 'URL'],
|
||||||
['description', 'Description']
|
['description', 'Description']
|
||||||
])}`
|
])}`,
|
||||||
|
'links'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).catch(next);
|
).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) => {
|
app.get('/sponsors', (_req, res, next) => {
|
||||||
withDatabase(databasePath, (db) =>
|
withDatabase(databasePath, (db) =>
|
||||||
res.send(
|
res.send(
|
||||||
@@ -36,7 +77,8 @@ export function createWebApp(databasePath: string) {
|
|||||||
['newsletter', 'Newsletter'],
|
['newsletter', 'Newsletter'],
|
||||||
['sponsor', 'Sponsor'],
|
['sponsor', 'Sponsor'],
|
||||||
['description', 'Description']
|
['description', 'Description']
|
||||||
])}`
|
])}`,
|
||||||
|
'sponsors'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).catch(next);
|
).catch(next);
|
||||||
@@ -52,7 +94,8 @@ export function createWebApp(databasePath: string) {
|
|||||||
['status', 'Status'],
|
['status', 'Status'],
|
||||||
['source', 'Source'],
|
['source', 'Source'],
|
||||||
['date', 'Date']
|
['date', 'Date']
|
||||||
])}`
|
])}`,
|
||||||
|
'dead-links'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).catch(next);
|
).catch(next);
|
||||||
@@ -71,7 +114,8 @@ export function createWebApp(databasePath: string) {
|
|||||||
['sponsors', 'Sponsors'],
|
['sponsors', 'Sponsors'],
|
||||||
['dead_links', 'Dead Links'],
|
['dead_links', 'Dead Links'],
|
||||||
['errors', 'Errors']
|
['errors', 'Errors']
|
||||||
])}`
|
])}`,
|
||||||
|
'runs'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).catch(next);
|
).catch(next);
|
||||||
@@ -87,6 +131,19 @@ export function createWebApp(databasePath: string) {
|
|||||||
return app;
|
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(
|
async function withDatabase(
|
||||||
databasePath: string,
|
databasePath: string,
|
||||||
callback: (database: CatalogDatabase) => void
|
callback: (database: CatalogDatabase) => void
|
||||||
|
|||||||
+411
-18
@@ -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 {
|
function escapeHtml(value: unknown): string {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
@@ -7,7 +23,27 @@ function escapeHtml(value: unknown): string {
|
|||||||
.replaceAll("'", ''');
|
.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>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -15,25 +51,247 @@ export function page(title: string, body: string): string {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${escapeHtml(title)}</title>
|
<title>${escapeHtml(title)}</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; margin: 2rem; color: #202124; }
|
:root {
|
||||||
nav a { margin-right: 1rem; }
|
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; }
|
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, td {
|
||||||
th { background: #f5f5f5; }
|
border-bottom: 1px solid var(--line);
|
||||||
.cards { display: flex; flex-wrap: wrap; gap: 1rem; }
|
padding: 0.65rem 0.5rem;
|
||||||
.card { border: 1px solid #ddd; padding: 1rem; min-width: 10rem; }
|
text-align: left;
|
||||||
.muted { color: #666; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<header class="topbar">
|
||||||
<a href="/">Dashboard</a>
|
<a class="brand" href="/">Newsletter Link Catalog</a>
|
||||||
<a href="/links">Links</a>
|
<nav class="global-nav" aria-label="Primary">
|
||||||
<a href="/sponsors">Sponsored Links</a>
|
${navLink('/', 'Dashboard', 'dashboard', active)}
|
||||||
<a href="/dead-links">Dead Links</a>
|
${navLink('/newsletters', 'Newsletters', 'newsletters', active)}
|
||||||
<a href="/runs">Runs</a>
|
${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>
|
</nav>
|
||||||
${body}
|
<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>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -51,13 +309,148 @@ export function dashboard(counts: Record<string, number>): string {
|
|||||||
return page(
|
return page(
|
||||||
'Newsletter Link Catalog',
|
'Newsletter Link Catalog',
|
||||||
`<h1>Newsletter Link Catalog</h1>
|
`<h1>Newsletter Link Catalog</h1>
|
||||||
<section class="cards">
|
<section class="cards" aria-label="Catalog totals">
|
||||||
${Object.entries(counts)
|
${Object.entries(counts)
|
||||||
.map(
|
.map(
|
||||||
([key, value]) =>
|
([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('')}
|
.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);
|
const db = new CatalogDatabase(path);
|
||||||
db.saveCatalogRun({
|
db.saveCatalogRun({
|
||||||
mode: 'test',
|
mode: 'test',
|
||||||
newslettersProcessed: 1,
|
newslettersProcessed: 2,
|
||||||
linksExtracted: 1,
|
linksExtracted: 3,
|
||||||
sponsorCount: 1,
|
sponsorCount: 1,
|
||||||
deadLinkCount: 1,
|
deadLinkCount: 1,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
rows: [
|
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',
|
'Issue Date': '2026-05-17',
|
||||||
Category: 'SQLite',
|
Category: 'SQLite',
|
||||||
@@ -48,6 +58,16 @@ function fixtureDatabase(): string {
|
|||||||
'Page Title + Meta': '',
|
'Page Title + Meta': '',
|
||||||
'Source Newsletter': 'DB Weekly',
|
'Source Newsletter': 'DB Weekly',
|
||||||
'Also In': ''
|
'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: [
|
sponsors: [
|
||||||
@@ -67,13 +87,79 @@ function fixtureDatabase(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('web app', () => {
|
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 () => {
|
it('renders dashboard and catalog pages from SQLite', async () => {
|
||||||
const path = fixtureDatabase();
|
const path = fixtureDatabase();
|
||||||
|
|
||||||
await expect(withServer(path, '/')).resolves.toContain('Newsletter Link Catalog');
|
await expect(withServer(path, '/')).resolves.toContain('Newsletter Link Catalog');
|
||||||
await expect(withServer(path, '/links')).resolves.toContain('SQLite Post');
|
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, '/sponsors')).resolves.toContain('Acme');
|
||||||
await expect(withServer(path, '/dead-links')).resolves.toContain('https://dead.example');
|
await expect(withServer(path, '/dead-links')).resolves.toContain('https://dead.example');
|
||||||
await expect(withServer(path, '/runs')).resolves.toContain('test');
|
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