History

fast search, readable UI, fix broken group links

Search - query parser: phrases, prefix*, -exclude, OR, from:, subject:, group:, after:/before: date bounds - meta_fts index over subject + from_addr (npm run index:meta) - drop the FTS->messages join from counts (11s -> ~25ms for "unix"); group scope seeks by rowid range instead of joining - highlighted snippets, relevance/newest/oldest sort, timing, clear errors UI - group links with @, spaces, commas no longer 404 (decode route params) - home: hierarchy overview, largest/all views, name filter, sortable columns - group: oldest/newest toggle, scoped search, windowed pager + jump-to-page - message: quote-depth tinting, dimmed signatures, clickable URLs, author/thread links, index link returns to the right page - decode RFC 2047 encoded subjects/authors - fixed table layout (no column overflow), 14px text, mobile layout - keyboard: j/k next/prev, u up, / search; help, 404, error, loading pages

Kris Yotam committed 1 month agomain

Commit c20846c1 parent 4ca247219 files · +2058 −474Browse files

Showing the changes to app (10 of 19 files). Show all

Unified · Split

10 files
  1. app/error.tsx +29 −0
  2. app/globals.css +302 −101
  3. app/group/[name]/[id]/page.tsx +128 −69
  4. app/group/[name]/page.tsx +112 −56
  5. app/help/page.tsx +78 −0
  6. app/layout.tsx +53 −13
  7. app/not-found.tsx +13 −0
  8. app/page.tsx +224 −23
  9. app/search/loading.tsx +9 −0
  10. app/search/page.tsx +220 −95
app/error.tsx+29−0
@@ -0,0 +1,29 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { useEffect } from "react";
5+
6+export default function Error({
7+ error,
8+ unstable_retry,
9+}: {
10+ error: Error & { digest?: string };
11+ unstable_retry: () => void;
12+}) {
13+ useEffect(() => {
14+ console.error(error);
15+ }, [error]);
16+ return (
17+ <main className="p9-win">
18+ <header className="p9-win-title">
19+ <h1 className="path">/usr/usenet/error</h1>
20+ </header>
21+ <div className="p9-error">Something broke rendering this page{error.digest ? ` (${error.digest})` : ""}.</div>
22+ <div className="p9-msg-nav">
23+ <button type="button" onClick={() => unstable_retry()}>retry</button>
24+ <Link href="/">groups</Link>
25+ <Link href="/search">search</Link>
26+ </div>
27+ </main>
28+ );
29+}
app/globals.css+302−101
@@ -1,139 +1,340 @@
1+/* Plan 9 / rio palette */
2+:root {
3+ --desk: #b5b5ad;
4+ --paper: #fffff5;
5+ --tag: #eaffff;
6+ --title: #ffffea;
7+ --border: #9eeeee;
8+ --rule: #888;
9+ --hair: #e2e2d8;
10+ --hover: #eeeee5;
11+ --ink: #111;
12+ --dim: #5a5a55;
13+ --faint: #8a8a82;
14+ --link: #000;
15+ --q1: #2f5f8f;
16+ --q2: #8a5a24;
17+ --q3: #4f7a3a;
18+ --mark: #ffff9e;
19+ --focus: #55aaaa;
20+ --mono: var(--font-mono), "IBM Plex Mono", "DejaVu Sans Mono", "Courier New", monospace;
21+}
22+
123 * { box-sizing: border-box; }
24+html { -webkit-text-size-adjust: 100%; }
225 body {
3− background: #B5B5AD;
4− font-family: "IBM Plex Mono", "Courier New", monospace;
5− font-size: 13px;
6− line-height: 1.45;
7− color: #000;
26+ background: var(--desk);
27+ color: var(--ink);
28+ font-family: var(--mono);
29+ font-size: 14px;
30+ line-height: 1.5;
831 margin: 0;
9− padding: 20px;
32+ padding: 20px 16px 12px;
33+}
34+a { color: var(--link); text-underline-offset: 2px; }
35+a:hover { text-decoration: underline; }
36+:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
37+mark { background: var(--mark); color: inherit; padding: 0 1px; }
38+kbd {
39+ font-family: inherit;
40+ font-size: 11px;
41+ border: 1px solid var(--rule);
42+ border-bottom-width: 2px;
43+ background: var(--title);
44+ padding: 0 4px;
1045 }
46+h1, h2 { font: inherit; margin: 0; }
47+
48+/* ---------- window chrome ---------- */
1149 .p9-win {
12− border: 2px solid #9EEEEE;
13− background: #FFFFF5;
14− max-width: 900px;
50+ border: 2px solid var(--border);
51+ background: var(--paper);
52+ max-width: 980px;
1553 margin: 0 auto;
54+ box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
1655 }
56+.p9-win.wide { max-width: 1180px; }
1757 .p9-win-title {
18− background: #FFFFEA;
19− border-bottom: 1px solid #888;
20− padding: 2px 8px;
21− font-size: 11px;
58+ background: var(--title);
59+ border-bottom: 1px solid var(--rule);
60+ padding: 3px 10px;
2261 display: flex;
2362 justify-content: space-between;
24− align-items: center;
25− min-height: 19px;
26− gap: 8px;
27−}
28−.p9-win-title .path { font-weight: bold; }
29−.p9-win-title .meta { color: #555; font-size: 10px; }
30−.p9-tagbar {
31− background: #EAFFFF;
32− border-bottom: 1px solid #888;
33− padding: 6px 10px;
34− font-size: 11px;
35− line-height: 1.7;
63+ align-items: baseline;
64+ gap: 12px;
65+ min-height: 24px;
3666 }
37−.p9-tagbar .hdr {
38− display: grid;
39− grid-template-columns: 90px 1fr;
67+.p9-win-title .path {
68+ font-weight: 700;
69+ font-size: 13px;
70+ overflow: hidden;
71+ text-overflow: ellipsis;
72+ white-space: nowrap;
73+ min-width: 0;
4074 }
41−.p9-tagbar .hdr-label { color: #555; }
75+.p9-win-title .meta { color: var(--dim); font-size: 12px; white-space: nowrap; }
76+
4277 .p9-bread {
43− background: #FFFFEA;
44− border-bottom: 1px solid #888;
45− padding: 3px 8px;
46− font-size: 11px;
47− color: #555;
78+ background: var(--title);
79+ border-bottom: 1px solid var(--rule);
80+ padding: 3px 10px;
81+ font-size: 13px;
82+ display: flex;
83+ justify-content: space-between;
84+ gap: 12px;
85+ flex-wrap: wrap;
4886 }
49−.p9-bread a { color: #000; text-decoration: none; }
87+.p9-bread a { text-decoration: none; }
5088 .p9-bread a:hover { text-decoration: underline; }
51−.p9-table { width: 100%; border-collapse: collapse; font-size: 12px; }
89+.p9-bread .crumbs { min-width: 0; overflow-wrap: anywhere; }
90+.p9-bread .sep, .p9-bread .here { color: var(--dim); }
91+.p9-bread .nav { display: flex; gap: 14px; }
92+
93+/* ---------- tag bars / toolbars ---------- */
94+.p9-bar {
95+ background: var(--tag);
96+ border-bottom: 1px solid var(--rule);
97+ padding: 6px 10px;
98+ font-size: 13px;
99+ display: flex;
100+ flex-wrap: wrap;
101+ gap: 6px 14px;
102+ align-items: center;
103+}
104+.p9-bar .spacer { flex: 1; }
105+.p9-bar .dim { color: var(--dim); }
106+.p9-bar a { text-decoration: none; }
107+.p9-bar a:hover { text-decoration: underline; }
108+.p9-bar a.on { font-weight: 700; text-decoration: underline; }
109+
110+input, select, button { font-family: inherit; font-size: 13px; color: var(--ink); }
111+input[type="text"], input[type="search"], input[type="number"], select {
112+ border: 1px solid var(--rule);
113+ background: var(--paper);
114+ padding: 3px 6px;
115+ border-radius: 0;
116+}
117+input[type="number"] { width: 6.5em; padding: 1px 4px; }
118+button {
119+ border: 1px solid var(--rule);
120+ background: var(--title);
121+ padding: 3px 12px;
122+ cursor: pointer;
123+}
124+button:hover { background: var(--hover); }
125+
126+.p9-search { display: flex; gap: 8px; align-items: center; flex: 1 1 420px; min-width: 0; }
127+.p9-search input[type="search"] { flex: 1; min-width: 0; font-size: 14px; }
128+.p9-search label { color: var(--dim); }
129+.p9-opts { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; color: var(--dim); }
130+.p9-opts label { display: inline-flex; gap: 4px; align-items: center; }
131+.p9-hint { color: var(--dim); font-size: 12px; }
132+.p9-hint code { color: var(--ink); background: var(--title); padding: 0 3px; }
133+
134+/* ---------- tables ---------- */
135+.p9-table {
136+ width: 100%;
137+ border-collapse: collapse;
138+ table-layout: fixed;
139+ font-size: 13px;
140+}
52141 .p9-table th {
53142 text-align: left;
54− border-bottom: 1px solid #888;
55− padding: 3px 8px;
56− font-size: 11px;
57− color: #555;
58− font-weight: normal;
143+ border-bottom: 1px solid var(--rule);
144+ padding: 4px 10px;
145+ font-size: 12px;
146+ color: var(--dim);
147+ font-weight: 400;
59148 white-space: nowrap;
60− background: #EAFFFF;
149+ background: var(--tag);
150+ position: sticky;
151+ top: 0;
152+ z-index: 1;
61153 }
154+.p9-table th a { color: var(--dim); text-decoration: none; }
155+.p9-table th a:hover, .p9-table th a.on { color: var(--ink); text-decoration: underline; }
62156 .p9-table td {
63− padding: 2px 8px;
64− border-bottom: 1px solid #deded8;
157+ padding: 3px 10px;
158+ border-bottom: 1px solid var(--hair);
65159 vertical-align: top;
160+ overflow: hidden;
161+ text-overflow: ellipsis;
66162 }
67163 .p9-table tr:last-child td { border-bottom: none; }
68−.p9-table tr:hover td { background: #EEEEE5; }
69−.p9-table a { color: #000; text-decoration: none; }
70−.p9-table a:hover { text-decoration: underline; }
71−.col-num { text-align: right; width: 52px; color: #555; font-size: 11px; }
72−.col-date { width: 88px; white-space: nowrap; color: #555; font-size: 11px; }
73−.col-count { text-align: right; width: 80px; color: #555; }
74−.col-from { width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #555; font-size: 11px; }
75−.p9-pager {
76− background: #EAFFFF;
77− border-top: 1px solid #888;
78− padding: 5px 10px;
79− font-size: 11px;
80− color: #555;
164+.p9-table tbody tr:hover td { background: var(--hover); }
165+.p9-table td a { text-decoration: none; }
166+.p9-table td a:hover { text-decoration: underline; }
167+.p9-table td a:visited { color: #3a3a6a; }
168+.p9-table .num { text-align: right; font-variant-numeric: tabular-nums; }
169+
170+.col-num { width: 72px; text-align: right; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
171+.col-date { width: 104px; white-space: nowrap; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
172+.col-count { width: 104px; text-align: right; color: var(--dim); font-variant-numeric: tabular-nums; }
173+.col-from { width: 230px; white-space: nowrap; color: var(--dim); font-size: 12px; }
174+.col-group { width: 210px; white-space: nowrap; }
175+.col-subj { overflow-wrap: anywhere; }
176+td.col-subj a { display: block; }
177+
178+/* ---------- home: hierarchies ---------- */
179+.p9-hier { border-bottom: 1px solid var(--rule); }
180+.p9-hier:last-of-type { border-bottom: none; }
181+.p9-hier > summary {
182+ list-style: none;
183+ cursor: pointer;
184+ padding: 6px 10px;
81185 display: flex;
82− gap: 14px;
83− align-items: center;
186+ gap: 12px;
187+ align-items: baseline;
188+ background: var(--paper);
189+ user-select: none;
190+}
191+.p9-hier > summary::-webkit-details-marker { display: none; }
192+.p9-hier > summary::before { content: "▸"; color: var(--dim); width: 1ch; }
193+.p9-hier[open] > summary::before { content: "▾"; }
194+.p9-hier > summary:hover { background: var(--hover); }
195+.p9-hier > summary .name { font-weight: 700; }
196+.p9-hier > summary .stat { color: var(--dim); font-size: 12px; margin-left: auto; font-variant-numeric: tabular-nums; }
197+.p9-hier[open] > summary { border-bottom: 1px solid var(--hair); background: var(--title); }
198+.bar-cell { position: relative; }
199+.bar {
200+ position: absolute; left: 0; top: 4px; bottom: 4px;
201+ background: #d8f4f4; z-index: 0;
202+}
203+.bar-cell span { position: relative; z-index: 1; }
204+
205+/* ---------- message ---------- */
206+.p9-headers {
207+ background: var(--tag);
208+ border-bottom: 1px solid var(--rule);
209+ padding: 8px 12px;
210+ font-size: 13px;
211+ line-height: 1.6;
212+ margin: 0;
213+ display: grid;
214+ grid-template-columns: max-content 1fr;
215+ column-gap: 14px;
84216 }
85−.p9-pager a { color: #000; text-decoration: none; }
86−.p9-pager a:hover { text-decoration: underline; }
217+.p9-headers dt { color: var(--dim); }
218+.p9-headers dd { margin: 0; overflow-wrap: anywhere; min-width: 0; }
219+.p9-headers dd .acts { color: var(--dim); font-size: 12px; margin-left: 10px; white-space: nowrap; }
220+.p9-headers dd .acts a { color: var(--dim); }
221+.p9-headers dd .acts a:hover { color: var(--ink); }
222+.p9-headers .subject { font-weight: 700; }
223+
87224 .p9-msg-body {
88− padding: 12px;
89− font-size: 12px;
225+ padding: 16px 18px 20px;
226+ font-family: inherit;
227+ font-size: 14px;
228+ line-height: 1.6;
90229 white-space: pre-wrap;
91− word-break: break-word;
92− line-height: 1.55;
230+ overflow-wrap: anywhere;
93231 margin: 0;
94− background: #FFFFF5;
232+ background: var(--paper);
233+ tab-size: 8;
95234 }
96−.q1 { color: #4477AA; }
97−.q2 { color: #AA7744; }
98−.p9-msg-nav {
99− background: #EAFFFF;
100− border-top: 1px solid #888;
101− padding: 4px 10px;
102− font-size: 11px;
235+.p9-msg-body a { color: #1f4f7f; }
236+.q1 { color: var(--q1); }
237+.q2 { color: var(--q2); }
238+.q3 { color: var(--q3); }
239+.attr { color: var(--dim); font-style: italic; }
240+.sig { color: var(--faint); }
241+
242+.p9-msg-nav, .p9-pager {
243+ background: var(--tag);
244+ border-top: 1px solid var(--rule);
245+ padding: 6px 10px;
246+ font-size: 13px;
103247 display: flex;
104− gap: 16px;
248+ flex-wrap: wrap;
249+ gap: 6px 16px;
105250 align-items: center;
106251 }
107−.p9-msg-nav a { color: #000; text-decoration: none; }
108−.p9-msg-nav a:hover { text-decoration: underline; }
252+.p9-msg-nav a, .p9-pager a { text-decoration: none; }
253+.p9-msg-nav a:hover, .p9-pager a:hover { text-decoration: underline; }
109254 .p9-msg-nav .right { margin-left: auto; }
110−.p9-empty { padding: 16px 12px; color: #555; font-size: 12px; }
111−a { color: #000; }
112−.p9-search {
113− background: #EAFFFF;
114− border-bottom: 1px solid #888;
115− padding: 5px 10px;
116− font-size: 11px;
255+.dim { color: var(--faint); }
256+.p9-pager .pages { display: flex; gap: 2px 8px; flex-wrap: wrap; align-items: center; }
257+.p9-pager .cur { font-weight: 700; border-bottom: 2px solid var(--ink); }
258+.p9-pager .gap { color: var(--faint); }
259+.p9-pager .jump { margin-left: auto; color: var(--dim); }
260+.p9-pager .count { color: var(--dim); }
261+
262+/* ---------- search results ---------- */
263+.p9-status {
264+ padding: 6px 12px;
265+ font-size: 12px;
266+ color: var(--dim);
267+ border-bottom: 1px solid var(--hair);
117268 display: flex;
118− gap: 8px;
119− align-items: center;
269+ gap: 16px;
270+ flex-wrap: wrap;
120271 }
121−.p9-search input {
122− font-family: inherit;
123− font-size: 11px;
124− border: 1px solid #888;
125− background: #FFFFF5;
126− padding: 1px 4px;
127− flex: 1;
128− max-width: 300px;
272+.p9-status .warn { color: #8a4a00; }
273+.p9-hits { list-style: none; margin: 0; padding: 0; }
274+.p9-hits li { padding: 10px 14px 11px; border-bottom: 1px solid var(--hair); }
275+.p9-hits li:last-child { border-bottom: none; }
276+.p9-hits li:hover { background: #fbfbef; }
277+.p9-hits .subj { font-weight: 700; font-size: 14px; text-decoration: none; overflow-wrap: anywhere; }
278+.p9-hits .subj:hover { text-decoration: underline; }
279+.p9-hits .subj:visited { color: #3a3a6a; }
280+.p9-hits .meta {
281+ font-size: 12px;
282+ color: var(--dim);
283+ margin-top: 1px;
284+ display: flex;
285+ flex-wrap: wrap;
286+ gap: 0 14px;
129287 }
130−.p9-search button {
131− font-family: inherit;
288+.p9-hits .meta a { color: var(--dim); text-decoration: none; }
289+.p9-hits .meta a:hover { color: var(--ink); text-decoration: underline; }
290+.p9-hits .excerpt {
291+ margin: 4px 0 0;
292+ font-size: 13px;
293+ line-height: 1.55;
294+ color: #2a2a28;
295+ overflow-wrap: anywhere;
296+ max-width: 88ch;
297+}
298+
299+.p9-empty { padding: 18px 14px; color: var(--dim); font-size: 13px; }
300+.p9-error { padding: 12px 14px; color: #8a1f1f; background: #fff3ee; border-bottom: 1px solid var(--hair); font-size: 13px; }
301+
302+/* ---------- help ---------- */
303+.p9-doc { padding: 12px 18px 18px; font-size: 13px; line-height: 1.6; }
304+.p9-doc h2 { font-weight: 700; margin: 14px 0 4px; }
305+.p9-doc h2:first-child { margin-top: 0; }
306+.p9-doc table { border-collapse: collapse; }
307+.p9-doc td { padding: 2px 18px 2px 0; vertical-align: top; }
308+.p9-doc td:first-child { white-space: nowrap; }
309+.p9-doc code { background: var(--title); padding: 0 3px; }
310+
311+.p9-foot {
312+ max-width: 980px;
313+ margin: 10px auto 0;
132314 font-size: 11px;
133− border: 1px solid #888;
134− background: #FFFFEA;
135− padding: 1px 8px;
136− cursor: pointer;
315+ color: #4a4a45;
316+ text-align: right;
317+}
318+
319+.visually-hidden {
320+ position: absolute !important;
321+ width: 1px; height: 1px;
322+ overflow: hidden; clip: rect(0 0 0 0);
323+ white-space: nowrap;
324+}
325+
326+/* ---------- small screens ---------- */
327+@media (max-width: 720px) {
328+ body { padding: 6px 4px; font-size: 14px; }
329+ .p9-win { border-width: 1px; }
330+ .col-from, .col-num, .hide-sm { display: none; }
331+ .col-date { width: 92px; }
332+ .col-count { width: 84px; }
333+ .col-group { width: 140px; }
334+ .p9-table td, .p9-table th { padding-left: 7px; padding-right: 7px; }
335+ .p9-pager .jump { margin-left: 0; }
336+ .p9-headers { grid-template-columns: 1fr; }
337+ .p9-headers dt { margin-top: 4px; }
338+ .p9-msg-body { padding: 12px 10px; font-size: 13px; }
339+ .p9-foot { display: none; }
137340 }
138−.p9-search button:hover { background: #EEEEE5; }
139−.highlight { background: #FFFFAA; }
app/group/[name]/[id]/page.tsx+128−69
@@ -1,92 +1,151 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
23 import { notFound } from "next/navigation";
34 import { getMessage, getAdjacentIds } from "@/lib/db";
5+import {
6+ authorAddress,
7+ authorName,
8+ decodeMime,
9+ decodeParam,
10+ fmtDate,
11+ formatBody,
12+ groupHref,
13+ messageHref,
14+ queryValue,
15+ searchHref,
16+} from "@/lib/format";
17+import { Window, Sep } from "@/components/Window";
418
5−function formatBody(body: string): React.ReactNode[] {
6− const lines = body.split("\n");
7− return lines.map((line, i) => {
8− if (line.startsWith(">>")) {
9− return (
10− <span key={i}>
11− <span className="q2">{line}</span>
12− {"\n"}
13− </span>
14− );
15− }
16− if (line.startsWith(">")) {
17− return (
18− <span key={i}>
19− <span className="q1">{line}</span>
20− {"\n"}
21− </span>
22− );
23− }
24− return <span key={i}>{line}{"\n"}</span>;
25− });
26−}
19+const PER_PAGE = 50;
2720
28−export default async function MessagePage({
29− params,
30−}: {
21+interface Props {
3122 params: Promise<{ name: string; id: string }>;
32−}) {
33− const { name, id: idStr } = await params;
34− const originalId = parseInt(idStr);
35− if (isNaN(originalId)) notFound();
23+}
3624
25+async function load(params: Props["params"]) {
26+ const p = await params;
27+ const name = decodeParam(p.name);
28+ if (!/^\d+$/.test(p.id)) return null;
29+ const originalId = parseInt(p.id, 10);
3730 const message = getMessage(name, originalId);
38− if (!message) notFound();
31+ return message ? { name, originalId, message } : null;
32+}
33+
34+export async function generateMetadata({ params }: Props): Promise<Metadata> {
35+ const r = await load(params);
36+ if (!r) return { title: "not found" };
37+ return {
38+ title: `${decodeMime(r.message.subject) || "(no subject)"} — ${r.name}`,
39+ description: `${authorName(decodeMime(r.message.from_addr))} · ${r.message.date || "undated"}`,
40+ };
41+}
42+
43+/** Thread key: the subject without Re:/Fwd: prefixes and [list] tags. */
44+function threadSubject(s: string): string {
45+ let t = s.trim();
46+ for (;;) {
47+ const n = t.replace(/^\s*((re|fwd?|aw|sv)(\[\d+\])?\s*:|\[[^\]]{1,40}\])\s*/i, "");
48+ if (n === t) break;
49+ t = n;
50+ }
51+ return t.replace(/\s*\(was:.*\)\s*$/i, "").trim();
52+}
53+
54+export default async function MessagePage({ params }: Props) {
55+ const r = await load(params);
56+ if (!r) notFound();
57+ const { name, originalId } = r;
58+ const message = {
59+ ...r.message,
60+ subject: decodeMime(r.message.subject),
61+ from_addr: decodeMime(r.message.from_addr),
62+ };
63+ const { prev, next, position } = getAdjacentIds(name, originalId);
64+ const indexPage = Math.floor(position / PER_PAGE) + 1;
65+ const indexHref = groupHref(name, indexPage > 1 ? `?page=${indexPage}` : "");
3966
40− const { prev, next } = getAdjacentIds(name, originalId);
67+ const addr = authorAddress(message.from_addr);
68+ const who = authorName(message.from_addr);
69+ const authorQuery = addr || who;
70+ const thread = threadSubject(message.subject);
4171
4272 return (
43− <div className="p9-win">
44− <div className="p9-win-title">
45− <span className="path">
46− /usr/usenet/{name}/{originalId}
47− </span>
48− </div>
49− <div className="p9-bread">
50− <Link href="/">usenet</Link> / <Link href={`/group/${name}`}>{name}</Link> / {originalId}
51− </div>
52− <div className="p9-tagbar">
53− <div className="hdr">
54− <span className="hdr-label">From:</span>
55− <span>{message.from_addr || "(unknown)"}</span>
56− </div>
57− <div className="hdr">
58− <span className="hdr-label">Date:</span>
59− <span>{message.date || "(unknown)"}</span>
60− </div>
61− <div className="hdr">
62− <span className="hdr-label">Subject:</span>
63− <span>{message.subject || "(no subject)"}</span>
64− </div>
73+ <Window
74+ path={`/usr/usenet/${name}/${originalId}`}
75+ meta={<>#{originalId}</>}
76+ crumbs={
77+ <>
78+ <Sep />
79+ <Link href={indexHref}>{name}</Link>
80+ <Sep />
81+ <span className="here">{originalId}</span>
82+ </>
83+ }
84+ >
85+ <dl className="p9-headers">
86+ <dt>From:</dt>
87+ <dd>
88+ {message.from_addr || "(unknown)"}
89+ {authorQuery && (
90+ <span className="acts">
91+ <Link href={searchHref({ q: `from:${queryValue(authorQuery)}`, group: name, sort: "newest" })}>
92+ posts here
93+ </Link>{" "}
94+ ·{" "}
95+ <Link href={searchHref({ q: `from:${queryValue(authorQuery)}`, sort: "newest" })}>
96+ everywhere
97+ </Link>
98+ </span>
99+ )}
100+ </dd>
101+ <dt>Date:</dt>
102+ <dd>{fmtDate(message.date)}</dd>
103+ <dt>Newsgroup:</dt>
104+ <dd>
105+ <Link href={indexHref}>{name}</Link>
106+ </dd>
107+ <dt>Subject:</dt>
108+ <dd>
109+ <span className="subject">{message.subject || "(no subject)"}</span>
110+ {thread && (
111+ <span className="acts">
112+ <Link href={searchHref({ q: `subject:${queryValue(thread)}`, group: name, sort: "oldest" })}>
113+ thread
114+ </Link>
115+ </span>
116+ )}
117+ </dd>
65118 {message.message_id && (
66− <div className="hdr">
67− <span className="hdr-label">Message-ID:</span>
68− <span>{message.message_id}</span>
69− </div>
119+ <>
120+ <dt>Message-ID:</dt>
121+ <dd>{message.message_id}</dd>
122+ </>
70123 )}
71− </div>
72− <pre className="p9-msg-body">{formatBody(message.body)}</pre>
73− <div className="p9-msg-nav">
124+ </dl>
125+
126+ <pre className="p9-msg-body">
127+ {message.body.trim() ? formatBody(message.body) : <span className="dim">(empty body)</span>}
128+ </pre>
129+
130+ <nav className="p9-msg-nav" aria-label="Message navigation">
74131 {prev !== null ? (
75− <Link href={`/group/${name}/${prev}`}>&#9666; prev</Link>
132+ <Link href={messageHref(name, prev)} rel="prev">
133+ &#9666; prev
134+ </Link>
76135 ) : (
77− <span style={{ color: "#999" }}>&#9666; prev</span>
136+ <span className="dim">&#9666; prev</span>
78137 )}
79− <Link href={`/group/${name}`}>index</Link>
138+ <Link href={indexHref} rel="up">
139+ index
140+ </Link>
80141 {next !== null ? (
81− <Link href={`/group/${name}/${next}`} className="right">
142+ <Link href={messageHref(name, next)} rel="next" className="right">
82143 next &#9656;
83144 </Link>
84145 ) : (
85− <span className="right" style={{ color: "#999" }}>
86− next &#9656;
87− </span>
146+ <span className="right dim">next &#9656;</span>
88147 )}
89− </div>
90− </div>
148+ </nav>
149+ </Window>
91150 );
92151 }
app/group/[name]/page.tsx+112−56
@@ -1,71 +1,127 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
23 import { notFound } from "next/navigation";
34 import { getNewsgroup, getMessages } from "@/lib/db";
5+import {
6+ decodeParam,
7+ fmt,
8+ fmtDate,
9+ groupHref,
10+ hierarchyOf,
11+ intParam,
12+ messageHref,
13+ decodeMime,
14+} from "@/lib/format";
15+import { Window, Sep } from "@/components/Window";
16+import { Pager } from "@/components/Pager";
417
5−export default async function GroupPage({
6− params,
7− searchParams,
8−}: {
18+const PER_PAGE = 50;
19+
20+interface Props {
921 params: Promise<{ name: string }>;
10− searchParams: Promise<{ page?: string }>;
11−}) {
12− const { name } = await params;
13− const { page: pageStr } = await searchParams;
22+ searchParams: Promise<{ page?: string; order?: string }>;
23+}
24+
25+export async function generateMetadata({ params }: Props): Promise<Metadata> {
26+ const name = decodeParam((await params).name);
27+ return { title: name };
28+}
29+
30+export default async function GroupPage({ params, searchParams }: Props) {
31+ const name = decodeParam((await params).name);
32+ const sp = await searchParams;
1433 const group = getNewsgroup(name);
1534 if (!group) notFound();
1635
17− const page = Math.max(1, parseInt(pageStr || "1"));
18− const perPage = 50;
19− const { messages, total } = getMessages(name, page, perPage);
20− const totalPages = Math.ceil(total / perPage);
36+ const order = sp.order === "desc" ? "desc" : "asc";
37+ const totalPages = Math.max(1, Math.ceil(group.message_count / PER_PAGE));
38+ const page = Math.min(intParam(sp.page), totalPages);
39+ const { messages } = getMessages(name, page, PER_PAGE, order);
40+
41+ const hrefFor = (p: number, o = order) => {
42+ const qs = new URLSearchParams();
43+ if (p > 1) qs.set("page", String(p));
44+ if (o === "desc") qs.set("order", "desc");
45+ const s = qs.toString();
46+ return groupHref(name, s ? `?${s}` : "");
47+ };
48+ const hier = hierarchyOf(name);
49+ const from = (page - 1) * PER_PAGE + 1;
50+ const to = Math.min(group.message_count, page * PER_PAGE);
2151
2252 return (
23− <div className="p9-win">
24− <div className="p9-win-title">
25− <span className="path">/usr/usenet/{name}</span>
26− <span className="meta">{total.toLocaleString()} messages</span>
27− </div>
28− <div className="p9-bread">
29− <Link href="/">usenet</Link> / {name} &middot;{" "}
30− <Link href={`/search?group=${name}`}>search</Link>
53+ <Window
54+ path={`/usr/usenet/${name}`}
55+ meta={
56+ <>
57+ {fmt(group.message_count)} messages · {fmtDate(group.first_date)} – {fmtDate(group.last_date)}
58+ </>
59+ }
60+ crumbs={
61+ <>
62+ <Sep />
63+ <Link href={`/?h=${encodeURIComponent(hier)}`} rel="up">
64+ {hier}
65+ </Link>
66+ <Sep />
67+ <span className="here">{name}</span>
68+ </>
69+ }
70+ >
71+ <div className="p9-bar">
72+ <form className="p9-search" action="/search" method="GET" role="search">
73+ <input type="hidden" name="group" value={name} />
74+ <label htmlFor="q">search {name}</label>
75+ <input id="q" type="search" name="q" placeholder="words, from:, subject:…" data-search autoComplete="off" />
76+ <button type="submit">find</button>
77+ </form>
78+ <span className="p9-opts">
79+ <Link href={hrefFor(1, "asc")} className={order === "asc" ? "on" : undefined}>
80+ oldest first
81+ </Link>
82+ <Link href={hrefFor(1, "desc")} className={order === "desc" ? "on" : undefined}>
83+ newest first
84+ </Link>
85+ </span>
3186 </div>
32− <table className="p9-table">
33− <thead>
34− <tr>
35− <th className="col-num">#</th>
36− <th>subject</th>
37− <th className="col-from">from</th>
38− <th className="col-date">date</th>
39− </tr>
40− </thead>
41− <tbody>
42− {messages.map((m) => (
43− <tr key={m.original_id}>
44− <td className="col-num">{m.original_id}</td>
45− <td>
46− <Link href={`/group/${name}/${m.original_id}`}>
47− {m.subject || "(no subject)"}
48− </Link>
49− </td>
50− <td className="col-from">{m.from_addr}</td>
51− <td className="col-date">{m.date}</td>
87+
88+ {messages.length === 0 ? (
89+ <div className="p9-empty">No messages on this page.</div>
90+ ) : (
91+ <table className="p9-table">
92+ <thead>
93+ <tr>
94+ <th className="col-num">#</th>
95+ <th>subject</th>
96+ <th className="col-from">from</th>
97+ <th className="col-date">date</th>
5298 </tr>
53− ))}
54− </tbody>
55− </table>
56− {totalPages > 1 && (
57− <div className="p9-pager">
58− {page > 1 && (
59− <Link href={`/group/${name}?page=${page - 1}`}>&#9666; prev</Link>
60− )}
61− <span>
62− page {page}/{totalPages}
63− </span>
64− {page < totalPages && (
65− <Link href={`/group/${name}?page=${page + 1}`}>next &#9656;</Link>
66− )}
67− </div>
99+ </thead>
100+ <tbody>
101+ {messages.map((m) => (
102+ <tr key={m.id}>
103+ <td className="col-num">{m.original_id}</td>
104+ <td className="col-subj">
105+ <Link href={messageHref(name, m.original_id)}>{decodeMime(m.subject) || "(no subject)"}</Link>
106+ </td>
107+ <td className="col-from" title={decodeMime(m.from_addr)}>
108+ {decodeMime(m.from_addr) || "—"}
109+ </td>
110+ <td className="col-date">{fmtDate(m.date)}</td>
111+ </tr>
112+ ))}
113+ </tbody>
114+ </table>
68115 )}
69− </div>
116+
117+ <Pager
118+ page={page}
119+ totalPages={totalPages}
120+ href={(p) => hrefFor(p)}
121+ action={groupHref(name)}
122+ hidden={{ order: order === "desc" ? "desc" : undefined }}
123+ label={`${fmt(from)}–${fmt(to)} of ${fmt(group.message_count)}`}
124+ />
125+ </Window>
70126 );
71127 }
app/help/page.tsx+78−0
@@ -0,0 +1,78 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Window, Sep } from "@/components/Window";
4+import { searchHref } from "@/lib/format";
5+
6+export const metadata: Metadata = { title: "help" };
7+
8+const SYNTAX: [string, string][] = [
9+ ["unix kernel", "messages containing both words (in the body)"],
10+ ['"bell labs"', "exact phrase"],
11+ ["plan9*", "prefix — plan9, plan9port, plan9fans…"],
12+ ["lisp -scheme", "exclude a word; also -\"phrase\", -from:x"],
13+ ["acme OR sam", "either term"],
14+ ["from:pike", "author name or address (also f:, by:)"],
15+ ['from:"dennis ritchie"', "author, as a phrase"],
16+ ["subject:minix", "subject line (also s:, title:)"],
17+ ["group:comp.lang.c", "one newsgroup (also in:, g:)"],
18+ ["after:1990 before:1992-06", "date bounds: YYYY, YYYY-MM or YYYY-MM-DD"],
19+];
20+
21+const KEYS: [string, string][] = [
22+ ["j →", "next message / next page"],
23+ ["k ←", "previous message / previous page"],
24+ ["u", "up: message → group index → hierarchy"],
25+ ["/", "focus the search box"],
26+ ["Esc", "leave the search box"],
27+];
28+
29+export default function Help() {
30+ return (
31+ <Window path="/usr/usenet/help" crumbs={<><Sep /><span className="here">help</span></>}>
32+ <div className="p9-doc">
33+ <h2>search syntax</h2>
34+ <table>
35+ <tbody>
36+ {SYNTAX.map(([q, d]) => (
37+ <tr key={q}>
38+ <td>
39+ <Link href={searchHref({ q })}>
40+ <code>{q}</code>
41+ </Link>
42+ </td>
43+ <td>{d}</td>
44+ </tr>
45+ ))}
46+ </tbody>
47+ </table>
48+ <p>
49+ Terms combine: <code>from:ritchie subject:unix after:1985</code>. Words match whole
50+ tokens, case-insensitively; punctuation splits words, so <code>bell-labs.com</code> is the
51+ phrase “bell labs com”. Results sort by best match; newest/oldest sorting is available when
52+ a query matches under 100,000 messages.
53+ </p>
54+
55+ <h2>keyboard</h2>
56+ <table>
57+ <tbody>
58+ {KEYS.map(([k, d]) => (
59+ <tr key={k}>
60+ <td>
61+ <code>{k}</code>
62+ </td>
63+ <td>{d}</td>
64+ </tr>
65+ ))}
66+ </tbody>
67+ </table>
68+
69+ <h2>reading</h2>
70+ <p>
71+ Quoted text is tinted by depth, attribution lines are italic, and signatures (after a{" "}
72+ <code>-- </code> line) are dimmed. On a message, <b>posts here</b> / <b>everywhere</b> list
73+ the author&apos;s other posts and <b>thread</b> finds messages with the same subject.
74+ </p>
75+ </div>
76+ </Window>
77+ );
78+}
app/layout.tsx+53−13
@@ -1,25 +1,65 @@
1−import type { Metadata } from "next";
1+import type { Metadata, Viewport } from "next";
22 import { IBM_Plex_Mono } from "next/font/google";
33 import "./globals.css";
44
5−const ibmPlexMono = IBM_Plex_Mono({
6− weight: ["400", "700"],
7− subsets: ["latin"],
5+const plex = IBM_Plex_Mono({
6+ weight: ["400", "500", "700"],
7+ style: ["normal", "italic"],
8+ subsets: ["latin", "latin-ext"],
9+ variable: "--font-mono",
10+ display: "swap",
811 });
912
1013 export const metadata: Metadata = {
11− title: "Usenet Archives",
12− description: "Usenet newsgroup archive viewer",
14+ title: { default: "Usenet Archives", template: "%s · Usenet Archives" },
15+ description: "Usenet newsgroup archive viewer, 1980–2014, with full-text search.",
1316 };
1417
15−export default function RootLayout({
16− children,
17−}: Readonly<{
18− children: React.ReactNode;
19−}>) {
18+export const viewport: Viewport = {
19+ width: "device-width",
20+ initialScale: 1,
21+ themeColor: "#B5B5AD",
22+};
23+
24+/*
25+ * Keyboard: j/→ next · k/← prev · u up · / search.
26+ * Acts on links marked rel=next / rel=prev / rel=up, so every page opts in
27+ * just by marking its links. Ignored while typing or with modifiers held.
28+ */
29+const keys = `
30+document.addEventListener("keydown", function (e) {
31+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
32+ var t = e.target, tag = t && t.tagName;
33+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (t && t.isContentEditable)) {
34+ if (e.key === "Escape") t.blur();
35+ return;
36+ }
37+ function go(rel) {
38+ var a = document.querySelector('a[rel~="' + rel + '"]');
39+ if (a) { e.preventDefault(); a.click(); }
40+ }
41+ switch (e.key) {
42+ case "j": case "ArrowRight": go("next"); break;
43+ case "k": case "ArrowLeft": go("prev"); break;
44+ case "u": go("up"); break;
45+ case "/":
46+ var q = document.querySelector("input[data-search]");
47+ e.preventDefault();
48+ if (q) { q.focus(); q.select(); } else { location.href = "/search"; }
49+ break;
50+ }
51+});`;
52+
53+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
2054 return (
21− <html lang="en">
22− <body className={ibmPlexMono.className}>{children}</body>
55+ <html lang="en" className={plex.variable}>
56+ <body>
57+ {children}
58+ <footer className="p9-foot">
59+ <kbd>j</kbd>/<kbd>k</kbd> next/prev · <kbd>u</kbd> up · <kbd>/</kbd> search
60+ </footer>
61+ <script dangerouslySetInnerHTML={{ __html: keys }} />
62+ </body>
2363 </html>
2464 );
2565 }
app/not-found.tsx+13−0
@@ -0,0 +1,13 @@
1+import Link from "next/link";
2+import { Window, Sep } from "@/components/Window";
3+
4+export default function NotFound() {
5+ return (
6+ <Window path="/usr/usenet/404" crumbs={<><Sep /><span className="here">not found</span></>}>
7+ <div className="p9-empty">
8+ No such group or message. Try the <Link href="/">group list</Link> or{" "}
9+ <Link href="/search">search</Link>.
10+ </div>
11+ </Window>
12+ );
13+}
app/page.tsx+224−23
@@ -1,43 +1,244 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
2−import { getNewsgroups } from "@/lib/db";
3+import { getNewsgroups, getTotals, type Newsgroup } from "@/lib/db";
4+import { fmt, fmtDate, groupHref, hierarchyOf } from "@/lib/format";
5+import { Window, Sep } from "@/components/Window";
36
4−export default function Home() {
5− const groups = getNewsgroups();
6− const totalMessages = groups.reduce((s, g) => s + g.message_count, 0);
7+type SortKey = "name" | "messages" | "first" | "last";
8+type View = "hier" | "all" | "top";
79
10+interface Props {
11+ searchParams: Promise<{ q?: string; h?: string; sort?: string; view?: string }>;
12+}
13+
14+export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
15+ const { h, q } = await searchParams;
16+ if (h) return { title: `${h}.*` };
17+ if (q) return { title: `groups matching “${q}”` };
18+ return {};
19+}
20+
21+const SORTS: Record<SortKey, (a: Newsgroup, b: Newsgroup) => number> = {
22+ name: (a, b) => a.name.localeCompare(b.name),
23+ messages: (a, b) => b.message_count - a.message_count || a.name.localeCompare(b.name),
24+ first: (a, b) =>
25+ (a.first_date ?? "9999").localeCompare(b.first_date ?? "9999") || a.name.localeCompare(b.name),
26+ last: (a, b) =>
27+ (b.last_date ?? "").localeCompare(a.last_date ?? "") || a.name.localeCompare(b.name),
28+};
29+
30+function homeHref(p: { q?: string; h?: string; sort?: string; view?: string }) {
31+ const sp = new URLSearchParams();
32+ if (p.view && p.view !== "hier") sp.set("view", p.view);
33+ if (p.h) sp.set("h", p.h);
34+ if (p.q) sp.set("q", p.q);
35+ if (p.sort && p.sort !== "name") sp.set("sort", p.sort);
36+ const s = sp.toString();
37+ return s ? `/?${s}` : "/";
38+}
39+
40+interface Hier {
41+ name: string;
42+ groups: number;
43+ messages: number;
44+ first: string | null;
45+ last: string | null;
46+}
47+
48+function hierarchies(groups: Newsgroup[]): Hier[] {
49+ const m = new Map<string, Hier>();
50+ for (const g of groups) {
51+ const k = hierarchyOf(g.name);
52+ let h = m.get(k);
53+ if (!h) m.set(k, (h = { name: k, groups: 0, messages: 0, first: null, last: null }));
54+ h.groups++;
55+ h.messages += g.message_count;
56+ if (g.first_date && (!h.first || g.first_date < h.first)) h.first = g.first_date;
57+ if (g.last_date && (!h.last || g.last_date > h.last)) h.last = g.last_date;
58+ }
59+ return [...m.values()].sort((a, b) => b.messages - a.messages || a.name.localeCompare(b.name));
60+}
61+
62+function GroupTable({
63+ groups,
64+ sort,
65+ linkFor,
66+}: {
67+ groups: Newsgroup[];
68+ sort: SortKey;
69+ linkFor: (s: SortKey) => string;
70+}) {
71+ const max = groups.reduce((m, g) => Math.max(m, g.message_count), 1);
72+ const th = (key: SortKey, label: string) => (
73+ <Link href={linkFor(key)} className={sort === key ? "on" : undefined}>
74+ {label}
75+ {sort === key ? " ▾" : ""}
76+ </Link>
77+ );
878 return (
9− <div className="p9-win">
10− <div className="p9-win-title">
11− <span className="path">/usr/usenet</span>
12− <span className="meta">
13− {groups.length} groups &middot; {totalMessages.toLocaleString()} messages
14− </span>
15− </div>
16− <div className="p9-bread">
17− <Link href="/search">search</Link>
18− </div>
79+ <table className="p9-table">
80+ <thead>
81+ <tr>
82+ <th>{th("name", "newsgroup")}</th>
83+ <th className="col-count">{th("messages", "messages")}</th>
84+ <th className="col-date">{th("first", "first")}</th>
85+ <th className="col-date">{th("last", "last")}</th>
86+ </tr>
87+ </thead>
88+ <tbody>
89+ {groups.map((g) => (
90+ <tr key={g.name}>
91+ <td className="col-subj">
92+ <Link href={groupHref(g.name)}>{g.name}</Link>
93+ </td>
94+ <td className="col-count bar-cell">
95+ <i
96+ className="bar"
97+ style={{ width: `${Math.max(1, (100 * Math.log10(g.message_count + 1)) / Math.log10(max + 1))}%` }}
98+ />
99+ <span>{fmt(g.message_count)}</span>
100+ </td>
101+ <td className="col-date">{fmtDate(g.first_date)}</td>
102+ <td className="col-date">{fmtDate(g.last_date)}</td>
103+ </tr>
104+ ))}
105+ </tbody>
106+ </table>
107+ );
108+}
109+
110+export default async function Home({ searchParams }: Props) {
111+ const sp = await searchParams;
112+ const q = (sp.q ?? "").trim();
113+ const h = sp.h?.trim() || undefined;
114+ const sort: SortKey = (["name", "messages", "first", "last"] as const).includes(sp.sort as SortKey)
115+ ? (sp.sort as SortKey)
116+ : "name";
117+ const view: View = sp.view === "all" || sp.view === "top" ? sp.view : "hier";
118+
119+ const all = getNewsgroups();
120+ const totals = getTotals();
121+
122+ let body: React.ReactNode;
123+ let crumbs: React.ReactNode = null;
124+ let meta = `${fmt(totals.groups)} groups · ${fmt(totals.messages)} messages`;
125+
126+ if (q || h || view !== "hier") {
127+ let list = all;
128+ if (h) list = list.filter((g) => hierarchyOf(g.name) === h);
129+ if (q) {
130+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
131+ list = list.filter((g) => {
132+ const n = g.name.toLowerCase();
133+ return words.every((w) => n.includes(w));
134+ });
135+ }
136+ const effSort: SortKey = view === "top" && !sp.sort ? "messages" : sort;
137+ list = [...list].sort(SORTS[effSort]);
138+ if (view === "top" && !q && !h) list = list.slice(0, 100);
139+
140+ const msgs = list.reduce((s, g) => s + g.message_count, 0);
141+ meta = `${fmt(list.length)} groups · ${fmt(msgs)} messages`;
142+ if (h) {
143+ crumbs = (
144+ <>
145+ <Sep />
146+ <span className="here">{h}{h.startsWith("(") ? "" : ".*"}</span>
147+ </>
148+ );
149+ }
150+ body =
151+ list.length === 0 ? (
152+ <div className="p9-empty">
153+ No groups match “{q}”.{" "}
154+ <Link href={`/search?q=${encodeURIComponent(q)}`}>Search messages for it instead →</Link>
155+ </div>
156+ ) : (
157+ <GroupTable
158+ groups={list}
159+ sort={effSort}
160+ linkFor={(s) => homeHref({ q, h, view, sort: s })}
161+ />
162+ );
163+ } else {
164+ const hs = hierarchies(all);
165+ const max = hs[0]?.messages ?? 1;
166+ body = (
19167 <table className="p9-table">
20168 <thead>
21169 <tr>
22− <th>newsgroup</th>
170+ <th>hierarchy</th>
171+ <th className="col-count hide-sm">groups</th>
23172 <th className="col-count">messages</th>
24173 <th className="col-date">first</th>
25174 <th className="col-date">last</th>
26175 </tr>
27176 </thead>
28177 <tbody>
29− {groups.map((g) => (
30− <tr key={g.name}>
31− <td>
32− <Link href={`/group/${g.name}`}>{g.name}</Link>
178+ {hs.map((x) => (
179+ <tr key={x.name}>
180+ <td className="col-subj">
181+ <Link href={homeHref({ h: x.name })}>
182+ {x.name}
183+ {x.name.startsWith("(") ? "" : ".*"}
184+ </Link>
185+ </td>
186+ <td className="col-count hide-sm">{fmt(x.groups)}</td>
187+ <td className="col-count bar-cell">
188+ <i
189+ className="bar"
190+ style={{ width: `${Math.max(1, (100 * Math.log10(x.messages + 1)) / Math.log10(max + 1))}%` }}
191+ />
192+ <span>{fmt(x.messages)}</span>
33193 </td>
34− <td className="col-count">{g.message_count.toLocaleString()}</td>
35− <td className="col-date">{g.first_date ?? ""}</td>
36− <td className="col-date">{g.last_date ?? ""}</td>
194+ <td className="col-date">{fmtDate(x.first)}</td>
195+ <td className="col-date">{fmtDate(x.last)}</td>
37196 </tr>
38197 ))}
39198 </tbody>
40199 </table>
41− </div>
200+ );
201+ }
202+
203+ return (
204+ <Window path={h ? `/usr/usenet/${h}` : "/usr/usenet"} meta={meta} crumbs={crumbs}>
205+ <div className="p9-bar">
206+ <form className="p9-search" action="/search" method="GET" role="search">
207+ <label htmlFor="q">search</label>
208+ <input
209+ id="q"
210+ type="search"
211+ name="q"
212+ placeholder="words, &quot;phrases&quot;, from:pike, subject:plan9"
213+ data-search
214+ autoComplete="off"
215+ />
216+ <button type="submit">find</button>
217+ </form>
218+ </div>
219+ <div className="p9-bar">
220+ <span className="dim">view</span>
221+ <Link href="/" className={view === "hier" && !q && !h ? "on" : undefined}>
222+ hierarchies
223+ </Link>
224+ <Link href={homeHref({ view: "top" })} className={view === "top" && !q ? "on" : undefined}>
225+ largest
226+ </Link>
227+ <Link href={homeHref({ view: "all" })} className={view === "all" && !q ? "on" : undefined}>
228+ all groups
229+ </Link>
230+ <span className="spacer" />
231+ <form action="/" method="GET" className="p9-opts">
232+ {h && <input type="hidden" name="h" value={h} />}
233+ {view !== "hier" && <input type="hidden" name="view" value={view} />}
234+ <label>
235+ filter{" "}
236+ <input type="text" name="q" defaultValue={q} placeholder="lisp, plan9…" size={16} />
237+ </label>
238+ {(q || h) && <Link href={view !== "hier" ? homeHref({ view }) : "/"}>clear</Link>}
239+ </form>
240+ </div>
241+ {body}
242+ </Window>
42243 );
43244 }
app/search/loading.tsx+9−0
@@ -0,0 +1,9 @@
1+import { Window, Sep } from "@/components/Window";
2+
3+export default function Loading() {
4+ return (
5+ <Window path="/usr/usenet/search" meta="searching…" crumbs={<><Sep /><span className="here">search</span></>}>
6+ <div className="p9-empty">Searching the archive…</div>
7+ </Window>
8+ );
9+}
app/search/page.tsx+220−95
@@ -1,108 +1,233 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
2−import { searchMessages } from "@/lib/db";
3+import { searchMessages, hasMetaIndex, type SearchSort } from "@/lib/db";
4+import { parseQuery } from "@/lib/query";
5+import {
6+ Highlight,
7+ authorAddress,
8+ authorName,
9+ decodeMime,
10+ fmt,
11+ fmtDate,
12+ groupHref,
13+ intParam,
14+ messageHref,
15+ queryValue,
16+ searchHref,
17+} from "@/lib/format";
18+import { Window, Sep } from "@/components/Window";
19+import { Pager } from "@/components/Pager";
320
4−export default async function SearchPage({
5− searchParams,
6−}: {
7− searchParams: Promise<{ q?: string; group?: string; page?: string }>;
8−}) {
9− const { q, group, page: pageStr } = await searchParams;
10− const page = Math.max(1, parseInt(pageStr || "1"));
11− const perPage = 50;
21+const PER_PAGE = 25;
22+const MAX_PAGE = 400; // deep OFFSETs on relevance order are not worth the cost
1223
13− if (!q) {
14− return (
15− <div className="p9-win">
16− <div className="p9-win-title">
17− <span className="path">/usr/usenet/search</span>
18− </div>
19− <div className="p9-bread">
20− <Link href="/">usenet</Link> / search
21− </div>
22− <form className="p9-search" action="/search" method="GET">
23− <span>Search:</span>
24− <input type="text" name="q" autoFocus />
24+interface Props {
25+ searchParams: Promise<{ q?: string; group?: string; page?: string; sort?: string }>;
26+}
27+
28+export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
29+ const { q } = await searchParams;
30+ return { title: q ? `“${q}”` : "search" };
31+}
32+
33+function Examples() {
34+ const ex: [string, string][] = [
35+ ['"rob pike" plan9', "phrase + word"],
36+ ["from:ritchie", "posts by an author"],
37+ ["subject:minix after:1991 before:1992", "subject in a date range"],
38+ ["lisp macro* -scheme", "prefix, exclusion"],
39+ ["group:comp.os.plan9 acme OR sam", "one group, either word"],
40+ ];
41+ return (
42+ <div className="p9-doc">
43+ <h2>examples</h2>
44+ <table>
45+ <tbody>
46+ {ex.map(([q, d]) => (
47+ <tr key={q}>
48+ <td>
49+ <Link href={searchHref({ q })}>
50+ <code>{q}</code>
51+ </Link>
52+ </td>
53+ <td className="dim">{d}</td>
54+ </tr>
55+ ))}
56+ </tbody>
57+ </table>
58+ <p className="p9-hint">
59+ Full syntax on the <Link href="/help">help</Link> page.
60+ </p>
61+ </div>
62+ );
63+}
64+
65+export default async function SearchPage({ searchParams }: Props) {
66+ const sp = await searchParams;
67+ const q = (sp.q ?? "").trim();
68+ const scopeGroup = sp.group?.trim() || undefined;
69+ const sortReq: SearchSort =
70+ sp.sort === "newest" || sp.sort === "oldest" ? sp.sort : "relevance";
71+ const page = Math.min(intParam(sp.page), MAX_PAGE);
72+
73+ const pq = q ? parseQuery(q) : null;
74+ if (pq && scopeGroup && !pq.group) pq.group = scopeGroup;
75+ const group = pq?.group ?? scopeGroup;
76+
77+ const res = pq ? searchMessages(pq, { page, perPage: PER_PAGE, sort: sortReq }) : null;
78+ const totalPages = res ? Math.min(MAX_PAGE, Math.ceil(res.total / PER_PAGE)) : 0;
79+
80+ const hrefFor = (p: number) =>
81+ searchHref({
82+ q,
83+ group: scopeGroup,
84+ sort: sortReq === "relevance" ? undefined : sortReq,
85+ page: p > 1 ? p : undefined,
86+ });
87+
88+ const meta = res && !res.error
89+ ? `${res.capped ? "over " : ""}${fmt(res.total)} ${res.total === 1 ? "result" : "results"} · ${(res.ms / 1000).toFixed(2)}s`
90+ : undefined;
91+
92+ return (
93+ <Window
94+ path="/usr/usenet/search"
95+ meta={meta}
96+ crumbs={
97+ <>
98+ <Sep />
99+ {group ? (
100+ <>
101+ <Link href="/search">search</Link>
102+ <Sep />
103+ <Link href={groupHref(group)} rel="up">
104+ {group}
105+ </Link>
106+ </>
107+ ) : (
108+ <span className="here">search</span>
109+ )}
110+ </>
111+ }
112+ >
113+ <div className="p9-bar">
114+ <form className="p9-search" action="/search" method="GET" role="search">
115+ <label htmlFor="q" className="visually-hidden">
116+ query
117+ </label>
118+ <input
119+ id="q"
120+ type="search"
121+ name="q"
122+ defaultValue={q}
123+ placeholder='words, "phrases", from:pike, subject:plan9, after:1990'
124+ autoFocus={!q}
125+ data-search
126+ autoComplete="off"
127+ />
128+ {scopeGroup && (
129+ <select name="group" defaultValue={scopeGroup} aria-label="Scope">
130+ <option value={scopeGroup}>in {scopeGroup}</option>
131+ <option value="">all groups</option>
132+ </select>
133+ )}
134+ <select name="sort" defaultValue={sortReq} aria-label="Sort">
135+ <option value="relevance">best match</option>
136+ <option value="newest">newest</option>
137+ <option value="oldest">oldest</option>
138+ </select>
25139 <button type="submit">find</button>
26140 </form>
27− <div className="p9-empty">Enter a search query above.</div>
28141 </div>
29− );
30− }
31142
32− const { messages, total } = searchMessages(q, group, page, perPage);
33− const totalPages = Math.ceil(total / perPage);
143+ {!q && <Examples />}
34144
35− return (
36− <div className="p9-win">
37− <div className="p9-win-title">
38− <span className="path">/usr/usenet/search</span>
39− <span className="meta">{total.toLocaleString()} results</span>
40− </div>
41− <div className="p9-bread">
42− <Link href="/">usenet</Link> / search
43− {group && (
44− <>
45− {" "}/ <Link href={`/group/${group}`}>{group}</Link>
46− </>
47− )}
48− </div>
49− <form className="p9-search" action="/search" method="GET">
50− <span>Search:</span>
51− <input type="text" name="q" defaultValue={q} />
52− {group && <input type="hidden" name="group" value={group} />}
53− <button type="submit">find</button>
54− </form>
55− {messages.length === 0 ? (
56− <div className="p9-empty">No results for &quot;{q}&quot;.</div>
57− ) : (
58− <table className="p9-table">
59− <thead>
60− <tr>
61− <th>group</th>
62− <th className="col-num">#</th>
63− <th>subject</th>
64− <th className="col-from">from</th>
65− </tr>
66− </thead>
67− <tbody>
68− {messages.map((m) => (
69− <tr key={m.id}>
70− <td>
71− <Link href={`/group/${m.newsgroup}`}>{m.newsgroup}</Link>
72− </td>
73− <td className="col-num">{m.original_id}</td>
74− <td>
75− <Link href={`/group/${m.newsgroup}/${m.original_id}`}>
76− {m.subject || "(no subject)"}
77− </Link>
78− </td>
79− <td className="col-from">{m.from_addr}</td>
80− </tr>
81− ))}
82− </tbody>
83− </table>
84− )}
85− {totalPages > 1 && (
86− <div className="p9-pager">
87− {page > 1 && (
88− <Link
89− href={`/search?q=${encodeURIComponent(q)}${group ? `&group=${group}` : ""}&page=${page - 1}`}
90− >
91− &#9666; prev
92− </Link>
145+ {res?.error && <div className="p9-error">{res.error}</div>}
146+
147+ {res && !res.error && (
148+ <>
149+ {(res.sortFallback || res.capped || !hasMetaIndex()) && (
150+ <div className="p9-status">
151+ {res.sortFallback && (
152+ <span className="warn">
153+ Too many matches to sort by date — showing best matches. Narrow the query (add
154+ words, group:, after:/before:) to sort by date.
155+ </span>
156+ )}
157+ {res.capped && !res.sortFallback && <span>Counting stopped at {fmt(res.total)}.</span>}
158+ {!hasMetaIndex() && <span>Header index missing — run npm run index:meta.</span>}
159+ </div>
93160 )}
94− <span>
95− page {page}/{totalPages}
96− </span>
97− {page < totalPages && (
98− <Link
99− href={`/search?q=${encodeURIComponent(q)}${group ? `&group=${group}` : ""}&page=${page + 1}`}
100− >
101− next &#9656;
102− </Link>
161+
162+ {res.hits.length === 0 ? (
163+ <div className="p9-empty">
164+ {res.total > 0 ? (
165+ <>
166+ Past the last page. <Link href={hrefFor(1)}>Back to page 1</Link>
167+ </>
168+ ) : (
169+ <>
170+ No messages match <b>{q}</b>
171+ {group ? <> in {group}</> : null}.
172+ {group && scopeGroup && (
173+ <>
174+ {" "}
175+ <Link href={searchHref({ q, sort: sortReq === "relevance" ? undefined : sortReq })}>
176+ Try all groups →
177+ </Link>
178+ </>
179+ )}
180+ </>
181+ )}
182+ </div>
183+ ) : (
184+ <ol className="p9-hits" start={(page - 1) * PER_PAGE + 1}>
185+ {res.hits.map((h) => {
186+ const who = authorName(decodeMime(h.from_addr));
187+ const authorKey = authorAddress(h.from_addr) || who;
188+ return (
189+ <li key={h.id}>
190+ <Link className="subj" href={messageHref(h.newsgroup, h.original_id)}>
191+ {h.subjectHl ? <Highlight text={decodeMime(h.subjectHl)} /> : decodeMime(h.subject) || "(no subject)"}
192+ </Link>
193+ <div className="meta">
194+ <Link href={groupHref(h.newsgroup)}>{h.newsgroup}</Link>
195+ <span>{fmtDate(h.date)}</span>
196+ {h.from_addr && (
197+ <Link
198+ href={searchHref({ q: `from:${queryValue(authorKey)}`, sort: "newest" })}
199+ title={h.from_addr}
200+ >
201+ {h.fromHl ? <Highlight text={decodeMime(h.fromHl)} /> : who}
202+ </Link>
203+ )}
204+ </div>
205+ {h.excerpt && (
206+ <p className="excerpt">
207+ <Highlight text={h.excerpt} />
208+ </p>
209+ )}
210+ </li>
211+ );
212+ })}
213+ </ol>
103214 )}
104− </div>
215+
216+ {totalPages > 1 && (
217+ <Pager
218+ page={page}
219+ totalPages={totalPages}
220+ href={hrefFor}
221+ action="/search"
222+ hidden={{
223+ q,
224+ group: scopeGroup,
225+ sort: sortReq === "relevance" ? undefined : sortReq,
226+ }}
227+ />
228+ )}
229+ </>
105230 )}
106− </div>
231+ </Window>
107232 );
108233 }