import Link from "next/link"; import BrowseHeader from "@/components/BrowseHeader"; import PhraseConsole from "@/components/PhraseConsole"; import ShortlistBar from "@/components/ShortlistBar"; import { episodeIds, isEpisode, isFillerQuery, parsePhraseQuery, searchPhrases, MAX_TERMS, PAGE_SIZE, type PhraseResult, } from "@/lib/phrases"; import { listShortlists, readShortlist } from "@/lib/shortlist"; export const dynamic = "force-dynamic"; // THE PHRASE CONSOLE: every occurrence of a word or phrase across 300 episodes // of word-level ASR, with the timestamp that addresses both the sound and the // picture. // // A STATIC segment under app/browse/, so it wins over app/browse/[song]/ -- // which would otherwise catch /browse/find, fail readSong() and 404. The same // trap /browse/decisions documents, and the e2e suite asserts it here too, // because when this regresses the page still renders perfectly in isolation // and only the URL stops working. // // SEARCH IS `?q=` AND SERVER-RENDERED, over a plain
. // Not live-as-you-type: nothing else in this codebase fetches a list from the // client, and a URL that can be pasted into a note and still mean the same // thing six weeks later is worth more than saving a keypress. The filters are // links for the same reason. type Params = Record; const toSearchParams = (p: Params): URLSearchParams => { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(p)) { const one = Array.isArray(v) ? v[0] : v; if (one != null) sp.set(k, one); } return sp; }; export default async function FindPage({ searchParams }: { searchParams: Promise }) { const raw = toSearchParams(await searchParams); const { query, errors } = parsePhraseQuery(raw); // NO `q` MEANS NO INDEX. Building 105 MB of blob to draw an empty search box // would be the entire cost of the feature paid for nothing -- the same // restraint listSongs() shows by never probing. A bare readdir is enough to // say how big the corpus is. const episodes = await episodeIds(); const unknownVideo = query.video !== null && !(await isEpisode(query.video)); let result: PhraseResult | null = null; if (query.terms.length && !unknownVideo) result = await searchPhrases(query); // The shortlists are small JSON and are read on every render, like every // other pile in this tool -- the CLI and a second tab write the same file, // and a cached copy would be a stale copy the moment either did. const slug = raw.get("list"); const [lists, active] = await Promise.all([ listShortlists(), slug ? readShortlist(slug) : Promise.resolve(null), ]); const starred = new Set(active?.picks.map((p) => p.id) ?? []); const notes = [ ...errors.map((e) => e.message), ...(unknownVideo ? [`no episode called ${query.video}`] : []), ...(slug && !active ? [`no shortlist called ${slug}`] : []), ...(result?.notes ?? []), ]; // Every filter is a link that changes searchParams. `page` resets on any // change but its own: page 7 of a different filter is a different list. const href = (next: Partial>) => { const p = new URLSearchParams(raw); for (const [k, v] of Object.entries(next)) { if (v) p.set(k, v); else p.delete(k); } if (!("page" in next)) p.delete("page"); const s = p.toString(); return `/browse/find${s ? `?${s}` : ""}`; }; const pages = result ? Math.max(1, Math.ceil(result.total / query.per)) : 1; const shown = result?.hits.length ?? 0; // Ask the predicate, never the prose. Matching a word out of a note this file // does not own is a link that disappears the next time the note is reworded. const filler = isFillerQuery(query.terms); return (
{/* Carried so submitting the box does not silently drop the filters already applied. A GET form sends only its own fields. */} {(["video", "conf", "clean", "dupes", "order", "per", "list"] as const).map((k) => raw.get(k) ? : null, )} strictly adjacent · up to {MAX_TERMS} terms · / to focus {notes.length > 0 && (
{notes.map((n, i) => (

{n}

))} {/* Trap 1, said out loud and with somewhere to go. Without this the first search anyone runs returns two rows and the console reads as broken. */} {filler && (

The fillers are in /sort {" "} — mined from the audio, not from the transcript.

)}
)} {result && ( <>
conf {[0.4, 0.9].map((c) => ( ))} order {query.video && ( )}

{result.total.toLocaleString()}{" "} {result.total === 1 ? "hit" : "hits"} across{" "} {result.videos}{" "} {result.videos === 1 ? "episode" : "episodes"} · showing{" "} {shown} · page{" "} {query.page} of {pages}

starred.has(h.id)).map((h) => h.id)} /> {result.total === 0 && (

Nothing said that, strictly adjacent, anywhere in the corpus.

)} {pages > 1 && ( )}
)} {!result && (

{episodes.length} episodes of word-level ASR, unindexed until something is asked of them. Type a phrase: every occurrence comes back with the second it was said at, and that second addresses the sound and the picture alike.

)}
); } function Chip({ href, on, label }: { href: string; on: boolean; label: string }) { return ( {label} ); }