diff --git a/src/lib/Search.fns.ts b/src/lib/Search.fns.ts index 07b7738..b0f7004 100644 --- a/src/lib/Search.fns.ts +++ b/src/lib/Search.fns.ts @@ -4,6 +4,8 @@ import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import type { Result } from './Search' +type DbResult = Omit & { pr_json: string } + /** Search TIPs with FTS5, supporting exact number shortcuts and prefix matching. */ export const query = createServerFn({ method: 'POST' }) .inputValidator(z.string()) @@ -18,9 +20,15 @@ export const query = createServerFn({ method: 'POST' }) const numMatch = trimmed.match(/^(?:tip-?)?(\d+)$/i) if (numMatch) { const row = await db - .prepare('SELECT number, title, authors, status FROM tips WHERE number = ?') + .prepare('SELECT number, title, authors, status, pr_json FROM tips WHERE number = ?') .bind(numMatch[1]) - .first<{ number: string; title: string; authors: string; status: string }>() + .first<{ + number: string + title: string + authors: string + status: string + pr_json: string + }>() if (row) return [ { @@ -30,6 +38,7 @@ export const query = createServerFn({ method: 'POST' }) status: row.status, snippet: '', rank: 0, + pr: row.pr_json ? JSON.parse(row.pr_json) : undefined, }, ] as Result[] } @@ -46,7 +55,7 @@ export const query = createServerFn({ method: 'POST' }) const [titleRows, allRows] = await Promise.all([ db .prepare( - `SELECT t.number, t.title, t.authors, t.status, + `SELECT t.number, t.title, t.authors, t.status, t.pr_json, snippet(tips_fts, 1, '', '', '…', 32) as snippet, bm25(tips_fts, 10, 50, 3, 5, 1) as rank FROM tips_fts @@ -56,10 +65,10 @@ export const query = createServerFn({ method: 'POST' }) LIMIT 10`, ) .bind(titleQuery) - .all(), + .all(), db .prepare( - `SELECT t.number, t.title, t.authors, t.status, + `SELECT t.number, t.title, t.authors, t.status, t.pr_json, COALESCE( NULLIF(snippet(tips_fts, 4, '', '', '…', 32), ''), NULLIF(snippet(tips_fts, 3, '', '', '…', 32), ''), @@ -73,13 +82,18 @@ export const query = createServerFn({ method: 'POST' }) LIMIT 20`, ) .bind(ftsQuery) - .all(), + .all(), ]) // Merge: title matches first, then remaining general matches const seen = new Set(titleRows.results.map((r) => r.number)) - return [ - ...titleRows.results, - ...allRows.results.filter((r) => !seen.has(r.number)), - ].slice(0, 20) + return [...titleRows.results, ...allRows.results.filter((r) => !seen.has(r.number))] + .slice(0, 20) + .map((r) => { + const { pr_json, ...result } = r + return { + ...result, + pr: pr_json ? JSON.parse(pr_json) : undefined, + } + }) }) diff --git a/src/lib/Search.ts b/src/lib/Search.ts index 4f34809..32ab1c0 100644 --- a/src/lib/Search.ts +++ b/src/lib/Search.ts @@ -1,4 +1,6 @@ /** Search result from FTS5 query. */ +import type { PrInfo } from './Tips' + export type Result = { number: string title: string @@ -6,4 +8,5 @@ export type Result = { status: string snippet: string rank: number + pr?: PrInfo | undefined } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0c8070a..08d12ab 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,6 +5,7 @@ import * as Tips from '#/lib/Tips.fns' import * as Search from '#/lib/Search.fns' import * as Config from '#/lib/Config' import type * as SearchTypes from '#/lib/Search' +import type * as TipTypes from '#/lib/Tips' export const Route = createFileRoute('/')({ loader: () => Tips.list(), @@ -84,6 +85,38 @@ function StatusBadge({ status, isPr }: { status: string; isPr?: boolean }) { ) } +function displayStatus(tip: Pick) { + return tip.pr ? 'Proposed' : tip.status +} + +function statusKey(status: string) { + return status.toLowerCase().replace(/\s+/g, '-') +} + +function StatusFilter({ + value, + statuses, + onChange, +}: { + value: string + statuses: string[] + onChange: (v: string) => void +}) { + return ( + + ) +} + function SearchBox({ value, onChange, @@ -148,7 +181,13 @@ function SearchBox({ ) } -function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[]; activeIndex: number }) { +function SearchResults({ + results, + activeIndex, +}: { + results: SearchTypes.Result[] + activeIndex: number +}) { if (results.length === 0) return

No results found.

@@ -196,7 +235,7 @@ function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[] {r.title} - + {r.snippet && ( @@ -220,6 +259,7 @@ function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[] function TipsIndex() { const tips = Route.useLoaderData() const [query, setQuery] = useQueryState('q', parseAsString.withDefault('')) + const [status, setStatus] = useQueryState('status', parseAsString.withDefault('')) const [results, setResults] = useState(null) const [searching, setSearching] = useState(false) const [activeIndex, setActiveIndex] = useState(-1) @@ -227,6 +267,15 @@ function TipsIndex() { const navigate = Route.useNavigate() const isSearching = query.length > 0 + const statuses = Array.from(new Set(tips.map(displayStatus))).sort((a, b) => a.localeCompare(b)) + const visibleTips = status ? tips.filter((tip) => statusKey(displayStatus(tip)) === status) : tips + const visibleResults = status + ? (results?.filter((result) => statusKey(displayStatus(result)) === status) ?? null) + : results + + useEffect(() => { + setActiveIndex(-1) + }, [status]) useEffect(() => { if (!query.trim()) { @@ -264,28 +313,31 @@ function TipsIndex() {
- { - if (!results?.length) return - setActiveIndex((prev) => { - if (dir === 'down') return prev < results.length - 1 ? prev + 1 : 0 - return prev > 0 ? prev - 1 : results.length - 1 - }) - }} - onCommit={() => { - if (results && activeIndex >= 0 && activeIndex < results.length) { - navigate({ - to: '/$tipId', - params: { tipId: results[activeIndex].number }, +
+ { + if (!visibleResults?.length) return + setActiveIndex((prev) => { + if (dir === 'down') return prev < visibleResults.length - 1 ? prev + 1 : 0 + return prev > 0 ? prev - 1 : visibleResults.length - 1 }) - } - }} - /> + }} + onCommit={() => { + if (visibleResults && activeIndex >= 0 && activeIndex < visibleResults.length) { + navigate({ + to: '/$tipId', + params: { tipId: visibleResults[activeIndex].number }, + }) + } + }} + /> + +
{isSearching ? ( - searching && !results ? ( + searching && !visibleResults ? (

) : ( - results && + visibleResults && ) ) : (

- - - - - - - - - - - {tips.map((tip) => ( - - - - +
List of Tempo Improvement Proposals
NumberTitleStatus
- - - - - - {tip.title} - - - -
+ + + + + + - ))} - -
List of Tempo Improvement Proposals
NumberTitleStatus
+ + + {visibleTips.map((tip) => ( + + + + + + + + + {tip.title} + + + + + + + ))} + +
)}
diff --git a/src/styles.css b/src/styles.css index eba4ba2..123552f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -360,7 +360,9 @@ figcaption { text-decoration: none; border-left: 1.5px solid transparent; margin-left: -0.75px; - transition: color 0.12s ease, border-color 0.12s ease; + transition: + color 0.12s ease, + border-color 0.12s ease; } .tip-toc-item a:hover { @@ -434,6 +436,32 @@ figcaption { margin-top: 0; } +/* ── TIP Index Controls ── */ +.tip-index-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6em 0.8em; +} + +.tip-status-filter { + display: inline-flex; + align-items: center; + gap: 0.45em; + color: var(--color-text-muted); + font-size: 0.92em; +} + +.tip-status-filter select { + font-family: var(--font-serif); + font-size: 1em; + padding: 0.2em 0.6em; + border: 1px solid var(--color-rule-light); + border-radius: 3px; + background: var(--color-bg); + color: var(--color-text); +} + /* ── Heading anchor links ── */ .heading-anchor { scroll-margin-top: 1em;