// SearchView: full-text transcript search + filters const { useState: useStateSV, useMemo: useMemoSV } = React; function SearchView({ data, setSelectedRecording, setView }) { const [query, setQuery] = useStateSV(''); const [filterSource, setFilterSource] = useStateSV([]); const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false); const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false); const results = useMemoSV(() => { if (!query.trim() && filterSource.length === 0 && !filterHasTranscript && !filterHasHighlights) return []; const q = query.toLowerCase().trim(); const matches = []; for (const r of data.recordings) { if (filterSource.length > 0 && !filterSource.includes(r.source)) continue; if (filterHasTranscript && !r.hasTranscript) continue; if (filterHasHighlights && r.highlights.length === 0) continue; if (!q) { matches.push({ recording: r, matchingSegments: [] }); continue; } const matchingSegments = r.transcript.filter(s => s.text.toLowerCase().includes(q)); if (matchingSegments.length > 0) { matches.push({ recording: r, matchingSegments }); } } return matches.sort((a, b) => b.matchingSegments.length - a.matchingSegments.length || b.recording.startTime - a.recording.startTime); }, [query, filterSource, filterHasTranscript, filterHasHighlights, data]); function toggleArr(arr, setArr, val) { setArr(prev => prev.includes(val) ? prev.filter(x => x !== val) : [...prev, val]); } function formatTime(secs) { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); const s = Math.floor(secs % 60); if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`; } function highlightText(text, query) { if (!query.trim()) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return ( {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } const SOURCES = ['portable', 'stationary', 'phone']; const hasFilters = query.trim() || filterSource.length > 0 || filterHasTranscript || filterHasHighlights; return (
{/* Search bar */}
setQuery(e.target.value)} placeholder="Search transcripts…" autoFocus style={{ width: '100%', background: 'var(--bg3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 14px 10px 36px', color: 'var(--text)', fontFamily: 'var(--font-sans)', fontSize: 15, outline: 'none', transition: 'border-color 0.15s', }} onFocus={e => e.target.style.borderColor = 'var(--amber)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> {query && ( )}
{/* Filters */}
Filter: {SOURCES.map(s => ( toggleArr(filterSource, setFilterSource, s)} /> ))}
setFilterHasTranscript(v => !v)} /> setFilterHasHighlights(v => !v)} />
{/* Results */}
{!hasFilters ? (
Search transcripts or apply filters
) : results.length === 0 ? (
No results found.
) : ( <>
{results.length} recording{results.length !== 1 ? 's' : ''} · {results.reduce((a, r) => a + r.matchingSegments.length, 0)} segment matches
{results.map(({ recording, matchingSegments }) => ( setSelectedRecording(recording, seekTime)} /> ))}
)}
); } function FilterChip({ label, active, colorClass, onClick }) { return ( ); } function SearchResultCard({ recording: r, matchingSegments, query, highlightText, formatTime, onOpen }) { const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`; const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); // First matching segment time, or null to start at beginning const firstMatchTime = matchingSegments.length > 0 ? matchingSegments[0].startSeconds : null; return (
{/* Card header - opens at first match */}
onOpen(firstMatchTime)} style={{ padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', borderBottom: matchingSegments.length > 0 ? '1px solid var(--border)' : 'none' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} > {dateStr} {timeStr} {r.source} {dur} {r.highlights.length > 0 && ( ◈ {r.highlights.length} )}
{matchingSegments.length > 0 && ( {matchingSegments.length} match{matchingSegments.length !== 1 ? 'es' : ''} )}
{/* Matching segments - each opens at its specific time */} {matchingSegments.slice(0, 3).map(seg => (
onOpen(seg.startSeconds)} style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', display: 'flex', gap: 10 }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} > {formatTime(seg.startSeconds)} {highlightText(seg.text, query)}
))} {matchingSegments.length > 3 && (
onOpen(firstMatchTime)} style={{ padding: '6px 14px', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }} onMouseEnter={e => e.currentTarget.style.color = 'var(--amber)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text3)'} >+{matchingSegments.length - 3} more →
)}
); } Object.assign(window, { SearchView, FilterChip, SearchResultCard });