// 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 });