// 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 (
setOpen(true)}
style={{
background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 999,
padding: '8px 16px', color: TOKEN.textSecondary, fontSize: 13, cursor: 'pointer',
fontFamily: 'inherit', alignSelf: 'flex-start',
}}
>
{t ? `+${total} 集亦有命中` : `+${total} episodes also match`}
);
};
// ─── Bottom mode switcher (always visible) ──────────────────────────────────
const BottomModeSwitcher = ({ lang, onSwitchMode }) => {
const t = lang === 'zh';
const chip = (mode, label) => (
onSwitchMode && onSwitchMode(mode)}
style={{
background: 'none', border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 999,
padding: '6px 14px', color: TOKEN.accent, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
}}
>
{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')}
onSwitchMode && onSwitchMode('semantic')}
style={{
background: TOKEN.accentDim, border: `1px solid ${TOKEN.accent}`, borderRadius: 999,
padding: '4px 12px', color: TOKEN.accent, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t ? '改用語意搜尋' : 'Switch to semantic'}
{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) => (
onExample && onExample(q)}
style={{
background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 999,
padding: '7px 14px', color: TOKEN.text, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit',
}}
>
{q}
))}
);
};
// ─── 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,
});