"use client"; // The results half of the old TranscriptSearch monolith: the results/chart // section plus its virtualized card list. Everything it renders comes from the // shared SearchSession, so it stays in lockstep with the persistent search bar // even though the two now live in different slots of the workspace shell. import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { MessageSquareIcon } from "lucide-react"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { useMediaQuery } from "../lib/useMediaQuery"; import { VirtualRow } from "./VirtualRow"; import { Checkbox } from "./ui/checkbox"; import type { LayerHit } from "./searchPipeline"; import { AgeRestrictedBadge, DuplicateBadge, LivestreamBadge, VideoStateBadge, VodExpiredBadge, } from "./badges"; import { duplicateJumpSeconds, duplicateSiblings, useDuplicates, type DuplicateLookup, } from "./duplicatesCache"; import type { DuplicateVideoRef } from "../lib/duplicates"; import { postPlatformLabel } from "../lib/posts"; import { splitId } from "./originId"; import { usePlayer } from "./PlayerProvider"; import { formatTimestamp } from "../lib/vtt"; import { vodExpiry } from "../lib/vodExpiry"; import { Button } from "./ui/button"; import { LayerSwatch } from "./LayerSwatch"; import { SCOPE_LABELS } from "./QueryLeafView"; import type { LayerScope } from "../lib/searchQuery"; import { ChartShapeControls } from "./charts/ChartShapeControls"; import { SearchChartPanel } from "./charts/SearchChartPanel"; import { useSearchSession, type LeafInfo, type ResultGroup, } from "./SearchSessionContext"; import { useSearchData } from "./SearchDataContext"; import type { PublishedTag } from "../lib/curatedTags"; import { inTrackLabel } from "../lib/captionTracks"; // Rough first-paint guess for one full result card (header + a couple of // leaf sections + a handful of hits). After mount, ResizeObserver measures // the card's actual height and the virtualizer re-positions later cards. const CARD_ROW_ESTIMATE_PX = 120; // How many hit rows a card shows before it asks. Eight is about a phone screen // of context and still enough to judge a match on a desktop. const CARD_HIT_CAP = 8; export default function SearchResults() { const { searchedThisPageLife, hasActiveQuery, resultGroups, totalHits, capped, indexLoading, summariesLoading, indexProgress, searching, processed, totalToProcess, pipelineActive, view, setView, showChartView, effectiveShape, setChartShape, chartSlugs, treeProgress, globalScopeSlugs, leavesById, orderedLeafIds, activeVideo, activeTime, openWithMode, setHitLimit, hitBatchSize, resultsCopied, copyResultsContext, selectedSlugs, selectedCount, toggleSelected, selectOnly, selectAllResults, clearSelection, } = useSearchSession(); const router = useRouter(); // Per-card "Ask": ground in exactly this video and cross to the chat. The // shared session survives the route change, so the chat picks it up live. const onAsk = useCallback( (slug: string) => { selectOnly(slug); router.push("/ask/"); }, [selectOnly, router], ); // Chart options: expanded on a wide screen (where there is room beside the // chart), collapsed on a phone (where it would push the chart off-screen), // and whatever the user last chose once they choose. const wide = useMediaQuery("(min-width: 1024px)"); const [chartOptionsOpen, setChartOptionsOpen] = useState(null); // Room for the fixed selection toolbar: without it the last card, "Load more // results" and the footer sit underneath it and cannot be reached. const selectionBar = view !== "chart" && resultGroups.length > 0 && selectedCount > 0; // A clear screen until the visitor asks: no count, no controls, no chart and // no listing — the bar, the page's intro and the footer, with the bar's // "Press Enter or click Search" line saying what to do. An empty Search shows // every video; a link with a query or a filter shows its results on load. // `resultGroups` is still computed underneath. if (!searchedThisPageLife) return null; return (
{/* Summary on one line, controls on the next below sm: at 390px the count, the two view tabs and Copy for AI cannot share a row. */}

{hasActiveQuery ? ( <> Matching videos{" "} {resultGroups.length > 0 ? `(${resultGroups.length} video${ resultGroups.length === 1 ? "" : "s" }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})` : "(0)"} ) : ( `All videos (${resultGroups.length})` )} {(indexLoading || (!hasActiveQuery && summariesLoading)) && ( loading index{indexProgress ? ` (${indexProgress})` : ""}… )} {searching && ( searched {processed}/{totalToProcess} {pipelineActive ? "…" : ""} )}

{hasActiveQuery && resultGroups.length > 0 && ( )}
{view !== "chart" && resultGroups.length > 0 && (
0 ? "max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:mb-0 max-lg:z-10 max-lg:border-t max-lg:border-border max-lg:bg-background/95 max-lg:px-4 max-lg:py-2 max-lg:pr-48 max-lg:backdrop-blur-sm max-lg:pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]" : "" }`} > {selectedCount > 0 ? `${selectedCount} selected for AI` : "Select videos to ask AI about a subset"} {/* The handoff itself. Without it the pinned row offers only "select more" and "undo", and the reason for pinning it goes away. The per-card Ask replaces the selection with one video; this one keeps what the reader built. */} {selectedCount > 0 && ( )} {selectedCount > 0 && ( )}
)} {view === "chart" ? (
setChartOptionsOpen((e.currentTarget as HTMLDetailsElement).open) } className="rounded-lg border border-border px-3 py-2" > Chart options
) : ( <> {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && (

Showing all videos — type a query above to narrow.

)} {hasActiveQuery ? !indexLoading && resultGroups.length === 0 && !pipelineActive && (

No matching videos.

) : !summariesLoading && resultGroups.length === 0 && (

No videos match the current filters.

)} {resultGroups.length > 0 && ( )} {capped && (
)} )}
); } // Hub mode only: when an archive in scope failed to answer, one plain line // above the results says how many did, names the ones that did not, and offers // a Retry for them. The results from the archives that answered render as // usual underneath — one member failing never empties the page. Nothing in // single-site mode (no `federation`), nothing while every archive is fine, and // nothing while any archive in scope is still loading (the chips show that), so // "N of M" always names every archive it did not count. function FederationStatus() { const { federation } = useSearchData(); if (!federation) return null; const scoped = federation.sites.filter((s) => s.status !== "off"); const failed = scoped.filter((s) => s.status === "failed"); // Only once every archive in scope has settled, so the count is final and // every archive not counted is named (the chips show what is still loading). if (failed.length === 0 || scoped.some((s) => s.status === "loading")) { return null; } const answered = scoped.filter((s) => s.status === "ready").length; const names = failed.map((s) => s.siteTitle); const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; return (

{answered} of {scoped.length}{" "} {scoped.length === 1 ? "archive" : "archives"} answered. {list}{" "} did not, so{" "} {names.length === 1 ? "its" : "their"} videos are not in these results.

); } // ─── Result rendering ──────────────────────────────────────────────────────── type ResultRow = | { kind: "card"; cardIndex: number; group: ResultGroup } | { kind: "gap"; cardIndex: number }; const GAP_ROW_PX = 12; function buildResultRows( resultGroups: ReadonlyArray, ): ResultRow[] { const out: ResultRow[] = []; resultGroups.forEach((g, cardIndex) => { out.push({ kind: "card", cardIndex, group: g }); if (cardIndex < resultGroups.length - 1) { out.push({ kind: "gap", cardIndex }); } }); return out; } function rowKey(row: ResultRow): string { return row.kind === "card" ? `c${row.cardIndex}` : `c${row.cardIndex}:gap`; } function VirtualResultList({ resultGroups, leavesById, orderedLeafIds, activeVideo, activeTime, openWithMode, selectedSlugs, onToggleSelect, onAsk, }: { resultGroups: ReadonlyArray; leavesById: ReadonlyMap; orderedLeafIds: ReadonlyArray; activeVideo: string | null; activeTime: number | null; openWithMode: (slug: string, hit?: LayerHit) => void; selectedSlugs: ReadonlySet; onToggleSelect: (slug: string) => void; onAsk: (slug: string) => void; }) { const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]); // Which cards have had their hit cap lifted. Lives HERE, not in the card: a // card unmounts as soon as it scrolls out of the window virtualizer, so state // held inside it would silently reset on every scroll past. const [expandedSlugs, setExpandedSlugs] = useState>( () => new Set(), ); const onToggleExpand = useCallback((slug: string) => { setExpandedSlugs((prev) => { const next = new Set(prev); if (next.has(slug)) next.delete(slug); else next.add(slug); return next; }); }, []); // "This exists elsewhere in the archive." One root-level JSON, fetched once; // absent on most sites, which is why every use of it is additive. const duplicates = useDuplicates(); const { openTranscript } = usePlayer(); // Opening a sibling is not opening a hit, so it goes straight to the player // rather than through openWithMode: there is no LayerHit to carry a scope, and // the target is always a video's transcript view. const openDuplicate = useCallback( (slug: string, seconds: number) => { openTranscript(slug, seconds, { mode: "transcript" }); }, [openTranscript], ); const listRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); useEffect(() => { if (typeof window === "undefined") return; const update = () => { const el = listRef.current; if (!el) return; setScrollMargin(el.getBoundingClientRect().top + window.scrollY); }; update(); const ro = new ResizeObserver(update); if (listRef.current) ro.observe(listRef.current); ro.observe(document.body); window.addEventListener("resize", update); return () => { ro.disconnect(); window.removeEventListener("resize", update); }; }, []); const rowVirtualizer = useWindowVirtualizer({ count: flatRows.length, // Card heights vary a lot (header-only tombstone vs. a card with // hundreds of hits), so estimateSize is only a rough first-paint // guess; ResizeObserver measures each card's actual height on mount // and the virtualizer re-positions later cards. The unit here is a // whole card, not a row inside one — there is no intra-card row // positioning to get wrong. estimateSize: (index) => flatRows[index]?.kind === "gap" ? GAP_ROW_PX : CARD_ROW_ESTIMATE_PX, // Cards are large; ~4 above/below the viewport is plenty of buffer. overscan: 4, scrollMargin, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); return (
{virtualItems.map((vi) => { const row = flatRows[vi.index]; return ( {row.kind === "gap" ? ( ); } const ResultCard = memo(function ResultCard({ group, leavesById, orderedLeafIds, activeVideo, activeTime, openWithMode, selected, onToggleSelect, onAsk, duplicates, openDuplicate, expanded, onToggleExpand, }: { group: ResultGroup; leavesById: ReadonlyMap; orderedLeafIds: ReadonlyArray; activeVideo: string | null; activeTime: number | null; openWithMode: (slug: string, hit?: LayerHit) => void; selected: boolean; onToggleSelect: (slug: string) => void; onAsk: (slug: string) => void; duplicates: DuplicateLookup; openDuplicate: (slug: string, seconds: number) => void; expanded: boolean; onToggleExpand: (slug: string) => void; }) { // The site's published tag vocabulary, for labelling this record's // `curatedTags`. A tag the site does not publish (hidden here, zero-count // here, or defined after this page was built) is still shown — by its id. // The fact is on the record either way, and a raw id is more honest than // silently dropping a tag someone put there. const { curatedTags: publishedTags } = useSearchData(); const tagById = useMemo>( () => new Map(publishedTags.map((t) => [t.id, t])), [publishedTags], ); // Other copies of this video elsewhere in the archive. // // FEDERATION GUARD: in hub mode a group's slug may be an origin-qualified id, // and /duplicates.json is same-origin only — a cross-origin row's bare slug // could collide with a local one and point the viewer at the wrong video. A // post has no duplicate story at all. Both are simply excluded. const siblings = useMemo(() => { if (group.post) return []; if (splitId(group.slug).origin !== "") return []; return duplicateSiblings(duplicates, group.slug); }, [duplicates, group.slug, group.post]); // Where the viewer currently is in THIS copy: the moment the card is showing // them (the player's playhead when this card is what's open, otherwise the top // hit). Only ever honoured for a sibling the detector measured as aligned. const hereSeconds = activeVideo === group.slug && activeTime !== null ? activeTime : group.hits[0]?.start; // Bucket hits per contributing leaf so the user sees one section per // layer rather than an interleaved mishmash. const buckets = useMemo(() => { const m = new Map(); for (const id of orderedLeafIds) m.set(id, []); for (const h of group.hits) { const arr = m.get(h.leafId); if (arr) arr.push(h); } return m; }, [group.hits, orderedLeafIds]); // A card with 500 hits is not a result, it is a wall — and it is why the // search page was 43,000px tall on a phone and unreadable on a desktop too. // Show the first few across all the layer sections, and let the reader ask // for the rest. The cap is per CARD, not per section, so there is exactly one // "Show all" control and it sits outside the hit lists. const shownBuckets = useMemo(() => { if (expanded) return buckets; let left = CARD_HIT_CAP; const m = new Map(); for (const [id, hits] of buckets) { const take = Math.min(left, hits.length); m.set(id, take === hits.length ? hits : hits.slice(0, take)); left -= take; } return m; }, [buckets, expanded]); const capped = group.hits.length > CARD_HIT_CAP; const cardRef = useRef(null); return (
{/* Below sm the header is two rows — (checkbox + title) then (badges, channel · date · N hits, Ask) — because a single truncating row turned every title into three characters and an ellipsis. */}
{/* Per-card selection: an inclusion checkbox that builds the AI grounding subset. Kept quiet — the boldness is the chat's grounding palette. */}
{siblings.length > 0 && ( )}
    {Array.from(shownBuckets.entries()).map(([leafId, hits]) => { if (hits.length === 0) return null; const leafInfo = leavesById.get(leafId); if (!leafInfo) return null; return (
  • {/* The builder's table, not a second copy of it: this chain predated the description and tags scopes and called both of them "Transcripts", which is where the hits did NOT come from. */} { SCOPE_LABELS[ sectionScope( leafInfo.scope, buckets.get(leafId) ?? hits, !!group.post, ) ] } {leafInfo.query} {buckets.get(leafId)?.length ?? hits.length} hit {(buckets.get(leafId)?.length ?? hits.length) === 1 ? "" : "s"}
      {hits.map((h, i) => ( ))}
  • ); })}
{capped && ( )}
); }); ResultCard.displayName = "ResultCard"; // The jump controls for a card's other copies. Lives outside the card's // open-the-video ); })}
); } // One hit row inside a card. Memoized so that (a) scroll-frame re-renders of // the parent list don't re-run highlight() for every hit, and (b) when the // modal target changes only the previously-active and newly-active rows // re-render — every other row bails out on shallow-equal props. const HitRow = memo(function HitRow({ slug, hit, query, useRegex, isActive, onOpen, noSeek = false, }: { slug: string; hit: LayerHit; query: string; useRegex: boolean; isActive: boolean; onOpen: (slug: string, hit?: LayerHit) => void; // Post hits have no timeline position, so the timestamp gutter would only // ever read "0:00". Suppress it rather than render a meaningless seek. noSeek?: boolean; }) { return (
  • ); }); HitRow.displayName = "HitRow"; // What a section's bar names. Its leaf's scope — except that a plain // ("transcripts") leaf reads what "Search in" ticks, so its section on a post's // card holds post hits, and on a video whose only matches were in its live chat // holds chat hits; the bar names what is there rather than a kind that was not // read. Mixed transcript and chat hits keep "Transcripts", each chat hit // wearing its "live chat" badge. function sectionScope( scope: LayerScope, hits: ReadonlyArray, isPost: boolean, ): LayerScope { if (scope !== "transcripts") return scope; if (isPost) return "posts"; if (hits.length > 0 && hits.every((h) => h.scope === "chat")) return "chat"; return scope; } // Which social platform a post came from. Reuses the TrackBadge shape so post // rows sit visually alongside chat rows rather than inventing a new idiom. function PostBadge({ platform }: { platform: string }) { return ( {postPlatformLabel(platform)} ); } function TrackBadge({ track }: { track: string }) { // An alternate English track of a transcript (lib/captionTracks.ts) says // which one: "in uploaded captions". const label = track === "live_chat" ? "live chat" : inTrackLabel(track); return ( {label} ); } function formatSeconds(s: number): string { const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const mm = String(m).padStart(2, "0"); const ss = String(sec).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } function highlight(text: string, q: string, useRegex = false) { if (!q) return text; const parts: React.ReactNode[] = []; if (useRegex) { let re: RegExp; try { re = new RegExp(q, "gi"); } catch { return text; } let last = 0; let m: RegExpExecArray | null; let key = 0; while ((m = re.exec(text)) !== null) { if (m.index > last) parts.push(text.slice(last, m.index)); parts.push( {m[0]} , ); last = m.index + m[0].length; if (m[0].length === 0) re.lastIndex++; } if (last < text.length) parts.push(text.slice(last)); return <>{parts}; } const lower = text.toLowerCase(); const ql = q.toLowerCase(); let i = 0; while (i < text.length) { const idx = lower.indexOf(ql, i); if (idx === -1) { parts.push(text.slice(i)); break; } if (idx > i) parts.push(text.slice(i, idx)); parts.push( {text.slice(idx, idx + q.length)} , ); i = idx + q.length; } return <>{parts}; }