Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f347fd1b | |||
| f4ff04f92b | |||
| 7da885ed25 |
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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