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
Showing the changes to components (2 of 19 files). Show all
Unified · Split
| @@ -0,0 +1,87 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { fmt } from "@/lib/format"; | |
| 3 | + | |
| 4 | +/** Windowed pager: « ‹ 1 … 4 5 [6] 7 8 … 120 › » plus a jump-to-page box. */ | |
| 5 | +export function Pager({ | |
| 6 | + page, | |
| 7 | + totalPages, | |
| 8 | + href, | |
| 9 | + action, | |
| 10 | + hidden = {}, | |
| 11 | + label, | |
| 12 | +}: { | |
| 13 | + page: number; | |
| 14 | + totalPages: number; | |
| 15 | + href: (p: number) => string; | |
| 16 | + /** form action for the jump box */ | |
| 17 | + action: string; | |
| 18 | + /** extra params the jump box must carry */ | |
| 19 | + hidden?: Record<string, string | undefined>; | |
| 20 | + label?: string; | |
| 21 | +}) { | |
| 22 | + if (totalPages <= 1) return label ? <div className="p9-pager"><span className="count">{label}</span></div> : null; | |
| 23 | + | |
| 24 | + const around = 2; | |
| 25 | + const pages: (number | "gap")[] = []; | |
| 26 | + const lo = Math.max(1, page - around); | |
| 27 | + const hi = Math.min(totalPages, page + around); | |
| 28 | + if (lo > 1) pages.push(1); | |
| 29 | + if (lo > 2) pages.push("gap"); | |
| 30 | + for (let p = lo; p <= hi; p++) pages.push(p); | |
| 31 | + if (hi < totalPages - 1) pages.push("gap"); | |
| 32 | + if (hi < totalPages) pages.push(totalPages); | |
| 33 | + | |
| 34 | + return ( | |
| 35 | + <nav className="p9-pager" aria-label="Pagination"> | |
| 36 | + {page > 1 ? ( | |
| 37 | + <Link href={href(page - 1)} rel="prev" className="step"> | |
| 38 | + ◂ prev | |
| 39 | + </Link> | |
| 40 | + ) : ( | |
| 41 | + <span className="step dim">◂ prev</span> | |
| 42 | + )} | |
| 43 | + <span className="pages"> | |
| 44 | + {pages.map((p, i) => | |
| 45 | + p === "gap" ? ( | |
| 46 | + <span key={`g${i}`} className="gap"> | |
| 47 | + … | |
| 48 | + </span> | |
| 49 | + ) : p === page ? ( | |
| 50 | + <span key={p} className="cur" aria-current="page"> | |
| 51 | + {fmt(p)} | |
| 52 | + </span> | |
| 53 | + ) : ( | |
| 54 | + <Link key={p} href={href(p)}> | |
| 55 | + {fmt(p)} | |
| 56 | + </Link> | |
| 57 | + ) | |
| 58 | + )} | |
| 59 | + </span> | |
| 60 | + {page < totalPages ? ( | |
| 61 | + <Link href={href(page + 1)} rel="next" className="step"> | |
| 62 | + next ▸ | |
| 63 | + </Link> | |
| 64 | + ) : ( | |
| 65 | + <span className="step dim">next ▸</span> | |
| 66 | + )} | |
| 67 | + <form action={action} method="GET" className="jump"> | |
| 68 | + {Object.entries(hidden).map(([k, v]) => | |
| 69 | + v ? <input key={k} type="hidden" name={k} value={v} /> : null | |
| 70 | + )} | |
| 71 | + <label> | |
| 72 | + page{" "} | |
| 73 | + <input | |
| 74 | + type="number" | |
| 75 | + name="page" | |
| 76 | + min={1} | |
| 77 | + max={totalPages} | |
| 78 | + defaultValue={page} | |
| 79 | + aria-label="Go to page" | |
| 80 | + />{" "} | |
| 81 | + / {fmt(totalPages)} | |
| 82 | + </label> | |
| 83 | + </form> | |
| 84 | + {label && <span className="count">{label}</span>} | |
| 85 | + </nav> | |
| 86 | + ); | |
| 87 | +} | |
| @@ -0,0 +1,42 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { ReactNode } from "react"; | |
| 3 | + | |
| 4 | +/** A rio-style window: title bar, breadcrumb/tag bar, body. */ | |
| 5 | +export function Window({ | |
| 6 | + path, | |
| 7 | + meta, | |
| 8 | + crumbs, | |
| 9 | + children, | |
| 10 | + wide, | |
| 11 | +}: { | |
| 12 | + path: string; | |
| 13 | + meta?: ReactNode; | |
| 14 | + crumbs?: ReactNode; | |
| 15 | + children: ReactNode; | |
| 16 | + wide?: boolean; | |
| 17 | +}) { | |
| 18 | + return ( | |
| 19 | + <main className={`p9-win${wide ? " wide" : ""}`}> | |
| 20 | + <header className="p9-win-title"> | |
| 21 | + <h1 className="path" title={path}> | |
| 22 | + {path} | |
| 23 | + </h1> | |
| 24 | + {meta && <span className="meta">{meta}</span>} | |
| 25 | + </header> | |
| 26 | + <nav className="p9-bread" aria-label="Breadcrumb"> | |
| 27 | + <span className="crumbs"> | |
| 28 | + <Link href="/">usenet</Link> | |
| 29 | + {crumbs} | |
| 30 | + </span> | |
| 31 | + <span className="nav"> | |
| 32 | + <Link href="/">groups</Link> | |
| 33 | + <Link href="/search">search</Link> | |
| 34 | + <Link href="/help">help</Link> | |
| 35 | + </span> | |
| 36 | + </nav> | |
| 37 | + {children} | |
| 38 | + </main> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export const Sep = () => <span className="sep"> / </span>; | |