2026-05-19 17:11:45 -04:00
|
|
|
|
// 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(() => {
|
2026-05-19 20:54:48 -04:00
|
|
|
|
if (!query.trim() && filterSource.length === 0 && !filterHasTranscript && !filterHasHighlights) return [];
|
2026-05-19 17:11:45 -04:00
|
|
|
|
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);
|
2026-05-19 20:54:48 -04:00
|
|
|
|
}, [query, filterSource, filterHasTranscript, filterHasHighlights, data]);
|
2026-05-19 17:11:45 -04:00
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{text.slice(0, idx)}
|
|
|
|
|
|
<mark style={{ background: 'var(--amber)', color: 'var(--bg)', borderRadius: 2, padding: '0 1px' }}>
|
|
|
|
|
|
{text.slice(idx, idx + query.length)}
|
|
|
|
|
|
</mark>
|
|
|
|
|
|
{text.slice(idx + query.length)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const SOURCES = ['portable', 'stationary', 'phone'];
|
|
|
|
|
|
|
2026-05-19 20:54:48 -04:00
|
|
|
|
const hasFilters = query.trim() || filterSource.length > 0 || filterHasTranscript || filterHasHighlights;
|
2026-05-19 17:11:45 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
|
|
|
|
{/* Search bar */}
|
|
|
|
|
|
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
|
|
|
|
|
|
<div style={{ position: 'relative', marginBottom: 12 }}>
|
|
|
|
|
|
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text3)', fontSize: 16, pointerEvents: 'none' }}>⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={e => 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 && (
|
|
|
|
|
|
<button onClick={() => setQuery('')} style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text3)', fontSize: 18 }}>×</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Filters */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Filter:</span>
|
|
|
|
|
|
{SOURCES.map(s => (
|
|
|
|
|
|
<FilterChip key={s} label={s} active={filterSource.includes(s)} colorClass={`source-${s}`} onClick={() => toggleArr(filterSource, setFilterSource, s)} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
|
|
|
|
|
|
<FilterChip label="has transcript" active={filterHasTranscript} colorClass="" onClick={() => setFilterHasTranscript(v => !v)} />
|
|
|
|
|
|
<FilterChip label="has highlights" active={filterHasHighlights} colorClass="" onClick={() => setFilterHasHighlights(v => !v)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Results */}
|
|
|
|
|
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
|
|
|
|
|
{!hasFilters ? (
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60%', gap: 8 }}>
|
|
|
|
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, color: 'var(--bg4)' }}>⌕</div>
|
|
|
|
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text3)' }}>Search transcripts or apply filters</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : results.length === 0 ? (
|
|
|
|
|
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: '24px 0', textAlign: 'center' }}>No results found.</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', marginBottom: 12 }}>
|
|
|
|
|
|
{results.length} recording{results.length !== 1 ? 's' : ''} · {results.reduce((a, r) => a + r.matchingSegments.length, 0)} segment matches
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
|
|
|
|
{results.map(({ recording, matchingSegments }) => (
|
|
|
|
|
|
<SearchResultCard
|
|
|
|
|
|
key={recording.id}
|
|
|
|
|
|
recording={recording}
|
|
|
|
|
|
matchingSegments={matchingSegments}
|
|
|
|
|
|
query={query}
|
|
|
|
|
|
highlightText={highlightText}
|
|
|
|
|
|
formatTime={formatTime}
|
2026-05-19 20:34:00 -04:00
|
|
|
|
onOpen={(seekTime) => setSelectedRecording(recording, seekTime)}
|
2026-05-19 17:11:45 -04:00
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FilterChip({ label, active, colorClass, onClick }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={active ? colorClass : ''}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 9px', borderRadius: 3,
|
|
|
|
|
|
background: active ? 'var(--bg4)' : 'var(--bg3)',
|
|
|
|
|
|
border: `1px solid ${active ? 'currentColor' : 'var(--border)'}`,
|
|
|
|
|
|
color: active ? undefined : 'var(--text3)',
|
|
|
|
|
|
textTransform: 'lowercase', cursor: 'pointer', transition: 'all 0.1s',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>{label}</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
|
|
|
|
|
{/* Card header - opens at first match */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => 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'}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{dateStr} {timeStr}</span>
|
|
|
|
|
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{dur}</span>
|
|
|
|
|
|
{r.highlights.length > 0 && (
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
|
|
{matchingSegments.length > 0 && (
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--teal)' }}>{matchingSegments.length} match{matchingSegments.length !== 1 ? 'es' : ''}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span style={{ color: 'var(--text3)', fontSize: 12 }}>→</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Matching segments - each opens at its specific time */}
|
|
|
|
|
|
{matchingSegments.slice(0, 3).map(seg => (
|
|
|
|
|
|
<div key={seg.id} onClick={() => 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'}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', flexShrink: 0, paddingTop: 2 }}>{formatTime(seg.startSeconds)}</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--text2)', lineHeight: 1.5 }}>
|
|
|
|
|
|
{highlightText(seg.text, query)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{matchingSegments.length > 3 && (
|
|
|
|
|
|
<div onClick={() => 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 →</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Object.assign(window, { SearchView, FilterChip, SearchResultCard });
|