// KeywordResults — index (keyword) mode sectioned results. // // Part of change `keyword-index-mode`; leaves unified by // `unified-segment-citation-card` (every per-segment leaf is now // SegmentCitationCard with two-color term highlight + play / jump buttons). // // Renders up to three vertically stacked sections from the // POST /shows/{id}/keyword-search response: // T1 (chunk-and) — chunks where every term matches in the same chunk // T2 (episode-and) — episodes where every term matches across pools // T3 (or-fallback) — loose OR hits, only when T1 + T2 are both empty // plus a bottom mode-switcher chip (always) and a zero-result empty state. // // `highlightTerms` (the two-color term highlighter) now lives in // SegmentCitationCard.jsx (the single canonical owner) — this file references // that global one and must NOT redeclare it. const KW_HARD_CAP = 100; const KW_PAGE_STEP = 5; // Map a keyword-search hit (or fetched transcript segment) to the segment shape // SegmentCitationCard consumes, enriching audio_url from the episodes list (the // keyword-search response carries none) so the play button can render. const _kwSegment = (hit, audioUrlFor, epId, epTitle) => { const episode_id = hit.episode_id != null ? hit.episode_id : epId; return { ...hit, episode_id, episode_title: hit.episode_title || epTitle, audio_url: hit.audio_url || (audioUrlFor ? audioUrlFor(episode_id) : null), }; }; const _kwSectionHeader = (label, count, unit) => (
{label} {count} {unit}
); // ─── T2 episode card ──────────────────────────────────────────────────────── // Inline expand fetches the episode's matching segments via `onExpand` and lists // them in-place (no navigation) as SegmentCitationCard items. const T2EpisodeCard = ({ item, terms, lang, onExpand, onPlaySegment, onJumpToTranscript, audioUrlFor }) => { const t = lang === 'zh'; const pc = item.pool_counts || { title: 0, description: 0, transcript: 0 }; const [open, setOpen] = React.useState(false); const [segs, setSegs] = React.useState(null); // null = not yet fetched const [loading, setLoading] = React.useState(false); const toggle = async () => { if (open) { setOpen(false); return; } setOpen(true); if (segs === null && onExpand) { setLoading(true); try { const fetched = await onExpand(item.episode_id); setSegs(fetched || []); } catch (_) { setSegs([]); } finally { setLoading(false); } } }; return (
{item.episode_title}
{t ? '命中分佈:' : 'Hits: '} {t ? '標題' : 'Title'} {pc.title} · {t ? '描述' : 'Desc'} {pc.description} ·{' '} {t ? '逐字稿' : 'Transcript'} {pc.transcript}
{open ? (t ? '收合' : 'Collapse') : t ? '展開查看各段' : 'View segments'} {open && (
{loading &&
{t ? '載入中…' : 'Loading…'}
} {!loading && segs && segs.length === 0 && (
{t ? '(此集無可顯示的命中段落)' : '(no matching segments to show)'}
)} {!loading && (segs || []).map((s, i) => ( onJumpToTranscript && onJumpToTranscript(seg)} /> ))}
)}
); }; // ─── T2 collapsed chip ────────────────────────────────────────────────────── const T2CollapsedChip = ({ total, lang, children }) => { const t = lang === 'zh'; const [open, setOpen] = React.useState(false); if (open) return children; return ( ); }; // ─── Bottom mode switcher (always visible) ────────────────────────────────── const BottomModeSwitcher = ({ lang, onSwitchMode }) => { const t = lang === 'zh'; const chip = (mode, label) => ( ); return (
{t ? '換個方式找:' : 'Try another way:'} {chip('semantic', t ? '語意搜尋' : 'Semantic')} {chip('chat', t ? '對話查詢' : 'Chat')}
); }; // ─── T3 OR fallback section ───────────────────────────────────────────────── const T3FallbackSection = ({ t3, terms, lang, onSwitchMode, onPlaySegment, onJumpToTranscript, audioUrlFor }) => { const t = lang === 'zh'; if (!t3) return null; return (
{_kwSectionHeader(t ? '鬆散結果' : 'Loose results', t3.total, t ? '段' : 'segments')}

{t ? '段內全部命中與全集命中皆無,以下為任一關鍵字的鬆散結果' : 'No strict matches; below are loose hits for any keyword'}

{(t3.items || []).map((hit) => ( onJumpToTranscript && onJumpToTranscript(seg)} /> ))}
); }; // ─── Zero-result empty state ──────────────────────────────────────────────── const _KW_STATIC_EXAMPLES = ['馬世芳', '滅火器', '歌單']; const KwEmptyState = ({ showId, lang, onSwitchMode, onExample }) => { const t = lang === 'zh'; const [examples, setExamples] = React.useState(_KW_STATIC_EXAMPLES); React.useEffect(() => { let alive = true; (async () => { try { const res = await apiFetch(`/shows/${showId}/trending-queries`); if (!res.ok) return; const data = await res.json(); const qs = (data && data.queries ? data.queries : []) .map((q) => q.query_text) .filter(Boolean) .slice(0, 3); if (alive && qs.length) setExamples(qs); } catch (_) { /* keep static fallback */ } })(); return () => { alive = false; }; }, [showId]); return (
🔎

{t ? '找不到符合的段落,換個關鍵字試試:' : 'No matches — try another keyword:'}

{examples.map((q, i) => ( ))}
); }; // ─── Main ─────────────────────────────────────────────────────────────────── const KeywordResults = ({ result, terms, showId, lang, onMoreT1, onMoreT2, onPlaySegment, onJumpToTranscript, audioUrlFor, onExpandEpisode, onSwitchMode, onExample, }) => { const t = lang === 'zh'; if (!result) return null; const t1 = result.t1 || { total: 0, items: [] }; const t2 = result.t2 || { total: 0, items: [], collapsed: false }; const t3 = result.t3; const totalAll = (t1.total || 0) + (t2.total || 0) + (t3 ? t3.total || 0 : 0); if (totalAll === 0) { return ; } const moreVisible = (loaded, total) => loaded < Math.min(total, KW_HARD_CAP); const t2Cards = (
{(t2.items || []).map((item) => ( ))} {moreVisible((t2.items || []).length, t2.total) && ( onMoreT2 && onMoreT2()}> {t ? '顯示更多 5 集' : 'Show 5 more episodes'} )}
); return (
{/* T1 — same-chunk AND */} {t1.total > 0 && (
{_kwSectionHeader(t ? '段內全部命中' : 'All terms in one segment', t1.total, t ? '段' : 'segments')}
{(t1.items || []).map((hit) => ( onJumpToTranscript && onJumpToTranscript(seg)} /> ))} {moreVisible((t1.items || []).length, t1.total) && ( onMoreT1 && onMoreT1()}> {t ? '顯示更多 5 段' : 'Show 5 more segments'} )}
)} {/* T2 — cross-pool episode AND */} {t2.total > 0 && (
{_kwSectionHeader(t ? '全集跨欄位命中' : 'All terms across an episode', t2.total, t ? '集' : 'episodes')} {t2.collapsed ? ( {t2Cards} ) : ( t2Cards )}
)} {/* T3 — OR fallback (only present when T1+T2 empty) */}
); }; Object.assign(window, { KeywordResults, T2EpisodeCard, T2CollapsedChip, T3FallbackSection, BottomModeSwitcher, });