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 app (10 of 19 files). Show all
Unified · Split
| @@ -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 | +} | |
| @@ -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 | + | |
| 1 | 23 | * { box-sizing: border-box; } |
| 24 | +html { -webkit-text-size-adjust: 100%; } | |
| 2 | 25 | 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; | |
| 8 | 31 | 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; | |
| 10 | 45 | } |
| 46 | +h1, h2 { font: inherit; margin: 0; } | |
| 47 | + | |
| 48 | +/* ---------- window chrome ---------- */ | |
| 11 | 49 | .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; | |
| 15 | 53 | margin: 0 auto; |
| 54 | + box-shadow: 0 1px 0 rgba(0, 0, 0, .08); | |
| 16 | 55 | } |
| 56 | +.p9-win.wide { max-width: 1180px; } | |
| 17 | 57 | .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; | |
| 22 | 61 | display: flex; |
| 23 | 62 | 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; | |
| 36 | 66 | } |
| 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; | |
| 40 | 74 | } |
| 41 | −.p9-tagbar .hdr-label { color: #555; } | |
| 75 | +.p9-win-title .meta { color: var(--dim); font-size: 12px; white-space: nowrap; } | |
| 76 | + | |
| 42 | 77 | .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; | |
| 48 | 86 | } |
| 49 | −.p9-bread a { color: #000; text-decoration: none; } | |
| 87 | +.p9-bread a { text-decoration: none; } | |
| 50 | 88 | .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 | +} | |
| 52 | 141 | .p9-table th { |
| 53 | 142 | 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; | |
| 59 | 148 | white-space: nowrap; |
| 60 | − background: #EAFFFF; | |
| 149 | + background: var(--tag); | |
| 150 | + position: sticky; | |
| 151 | + top: 0; | |
| 152 | + z-index: 1; | |
| 61 | 153 | } |
| 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; } | |
| 62 | 156 | .p9-table td { |
| 63 | − padding: 2px 8px; | |
| 64 | − border-bottom: 1px solid #deded8; | |
| 157 | + padding: 3px 10px; | |
| 158 | + border-bottom: 1px solid var(--hair); | |
| 65 | 159 | vertical-align: top; |
| 160 | + overflow: hidden; | |
| 161 | + text-overflow: ellipsis; | |
| 66 | 162 | } |
| 67 | 163 | .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; | |
| 81 | 185 | 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; | |
| 84 | 216 | } |
| 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 | + | |
| 87 | 224 | .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; | |
| 90 | 229 | white-space: pre-wrap; |
| 91 | − word-break: break-word; | |
| 92 | − line-height: 1.55; | |
| 230 | + overflow-wrap: anywhere; | |
| 93 | 231 | margin: 0; |
| 94 | − background: #FFFFF5; | |
| 232 | + background: var(--paper); | |
| 233 | + tab-size: 8; | |
| 95 | 234 | } |
| 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; | |
| 103 | 247 | display: flex; |
| 104 | − gap: 16px; | |
| 248 | + flex-wrap: wrap; | |
| 249 | + gap: 6px 16px; | |
| 105 | 250 | align-items: center; |
| 106 | 251 | } |
| 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; } | |
| 109 | 254 | .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); | |
| 117 | 268 | display: flex; |
| 118 | − gap: 8px; | |
| 119 | − align-items: center; | |
| 269 | + gap: 16px; | |
| 270 | + flex-wrap: wrap; | |
| 120 | 271 | } |
| 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; | |
| 129 | 287 | } |
| 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; | |
| 132 | 314 | 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; } | |
| 137 | 340 | } |
| 138 | −.p9-search button:hover { background: #EEEEE5; } | |
| 139 | −.highlight { background: #FFFFAA; } | |
| @@ -1,92 +1,151 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | import Link from "next/link"; |
| 2 | 3 | import { notFound } from "next/navigation"; |
| 3 | 4 | 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"; | |
| 4 | 18 | |
| 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; | |
| 27 | 20 | |
| 28 | −export default async function MessagePage({ | |
| 29 | − params, | |
| 30 | −}: { | |
| 21 | +interface Props { | |
| 31 | 22 | 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 | +} | |
| 36 | 24 | |
| 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); | |
| 37 | 30 | 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}` : ""); | |
| 39 | 66 | |
| 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); | |
| 41 | 71 | |
| 42 | 72 | 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> | |
| 65 | 118 | {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 | + </> | |
| 70 | 123 | )} |
| 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"> | |
| 74 | 131 | {prev !== null ? ( |
| 75 | − <Link href={`/group/${name}/${prev}`}>◂ prev</Link> | |
| 132 | + <Link href={messageHref(name, prev)} rel="prev"> | |
| 133 | + ◂ prev | |
| 134 | + </Link> | |
| 76 | 135 | ) : ( |
| 77 | − <span style={{ color: "#999" }}>◂ prev</span> | |
| 136 | + <span className="dim">◂ prev</span> | |
| 78 | 137 | )} |
| 79 | − <Link href={`/group/${name}`}>index</Link> | |
| 138 | + <Link href={indexHref} rel="up"> | |
| 139 | + index | |
| 140 | + </Link> | |
| 80 | 141 | {next !== null ? ( |
| 81 | − <Link href={`/group/${name}/${next}`} className="right"> | |
| 142 | + <Link href={messageHref(name, next)} rel="next" className="right"> | |
| 82 | 143 | next ▸ |
| 83 | 144 | </Link> |
| 84 | 145 | ) : ( |
| 85 | − <span className="right" style={{ color: "#999" }}> | |
| 86 | − next ▸ | |
| 87 | − </span> | |
| 146 | + <span className="right dim">next ▸</span> | |
| 88 | 147 | )} |
| 89 | − </div> | |
| 90 | − </div> | |
| 148 | + </nav> | |
| 149 | + </Window> | |
| 91 | 150 | ); |
| 92 | 151 | } |
| @@ -1,71 +1,127 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | import Link from "next/link"; |
| 2 | 3 | import { notFound } from "next/navigation"; |
| 3 | 4 | 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"; | |
| 4 | 17 | |
| 5 | −export default async function GroupPage({ | |
| 6 | − params, | |
| 7 | − searchParams, | |
| 8 | −}: { | |
| 18 | +const PER_PAGE = 50; | |
| 19 | + | |
| 20 | +interface Props { | |
| 9 | 21 | 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; | |
| 14 | 33 | const group = getNewsgroup(name); |
| 15 | 34 | if (!group) notFound(); |
| 16 | 35 | |
| 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); | |
| 21 | 51 | |
| 22 | 52 | 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} ·{" "} | |
| 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> | |
| 31 | 86 | </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> | |
| 52 | 98 | </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}`}>◂ prev</Link> | |
| 60 | − )} | |
| 61 | − <span> | |
| 62 | − page {page}/{totalPages} | |
| 63 | − </span> | |
| 64 | − {page < totalPages && ( | |
| 65 | − <Link href={`/group/${name}?page=${page + 1}`}>next ▸</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> | |
| 68 | 115 | )} |
| 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> | |
| 70 | 126 | ); |
| 71 | 127 | } |
| @@ -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's other posts and <b>thread</b> finds messages with the same subject. | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </Window> | |
| 77 | + ); | |
| 78 | +} | |
| @@ -1,25 +1,65 @@ | ||
| 1 | −import type { Metadata } from "next"; | |
| 1 | +import type { Metadata, Viewport } from "next"; | |
| 2 | 2 | import { IBM_Plex_Mono } from "next/font/google"; |
| 3 | 3 | import "./globals.css"; |
| 4 | 4 | |
| 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", | |
| 8 | 11 | }); |
| 9 | 12 | |
| 10 | 13 | 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.", | |
| 13 | 16 | }; |
| 14 | 17 | |
| 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 }>) { | |
| 20 | 54 | 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> | |
| 23 | 63 | </html> |
| 24 | 64 | ); |
| 25 | 65 | } |
| @@ -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 | +} | |
| @@ -1,43 +1,244 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | 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"; | |
| 3 | 6 | |
| 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"; | |
| 7 | 9 | |
| 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 | + ); | |
| 8 | 78 | 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 · {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 = ( | |
| 19 | 167 | <table className="p9-table"> |
| 20 | 168 | <thead> |
| 21 | 169 | <tr> |
| 22 | − <th>newsgroup</th> | |
| 170 | + <th>hierarchy</th> | |
| 171 | + <th className="col-count hide-sm">groups</th> | |
| 23 | 172 | <th className="col-count">messages</th> |
| 24 | 173 | <th className="col-date">first</th> |
| 25 | 174 | <th className="col-date">last</th> |
| 26 | 175 | </tr> |
| 27 | 176 | </thead> |
| 28 | 177 | <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> | |
| 33 | 193 | </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> | |
| 37 | 196 | </tr> |
| 38 | 197 | ))} |
| 39 | 198 | </tbody> |
| 40 | 199 | </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, "phrases", 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> | |
| 42 | 243 | ); |
| 43 | 244 | } |
| @@ -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 | +} | |
| @@ -1,108 +1,233 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | 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"; | |
| 3 | 20 | |
| 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 | |
| 12 | 23 | |
| 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> | |
| 25 | 139 | <button type="submit">find</button> |
| 26 | 140 | </form> |
| 27 | − <div className="p9-empty">Enter a search query above.</div> | |
| 28 | 141 | </div> |
| 29 | − ); | |
| 30 | − } | |
| 31 | 142 | |
| 32 | − const { messages, total } = searchMessages(q, group, page, perPage); | |
| 33 | − const totalPages = Math.ceil(total / perPage); | |
| 143 | + {!q && <Examples />} | |
| 34 | 144 | |
| 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 "{q}".</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 | − ◂ 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> | |
| 93 | 160 | )} |
| 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 ▸ | |
| 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> | |
| 103 | 214 | )} |
| 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 | + </> | |
| 105 | 230 | )} |
| 106 | − </div> | |
| 231 | + </Window> | |
| 107 | 232 | ); |
| 108 | 233 | } |