clio/clio-ui/components/SearchView.jsx
Your Name 528813d4a0
All checks were successful
Deploy UI / deploy (push) Successful in 1s
fixing bugs
2026-05-19 20:34:00 -04:00

212 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 [filterQuality, setFilterQuality] = useStateSV([]);
const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false);
const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false);
const results = useMemoSV(() => {
if (!query.trim() && filterSource.length === 0 && filterQuality.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 (filterQuality.length > 0 && !filterQuality.includes(r.quality)) 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, filterQuality, 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 (
<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'];
const QUALITIES = ['excellent', 'good', 'fair', 'poor'];
const hasFilters = query.trim() || filterSource.length > 0 || filterQuality.length > 0 || filterHasTranscript || filterHasHighlights;
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)' }} />
{QUALITIES.map(q => (
<FilterChip key={q} label={q} active={filterQuality.includes(q)} colorClass={`q-${q}`} onClick={() => toggleArr(filterQuality, setFilterQuality, q)} />
))}
<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}
onOpen={(seekTime) => setSelectedRecording(recording, seekTime)}
/>
))}
</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>
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{r.quality}</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 });