clio/clio-ui/components/RecordingDetail.jsx

629 lines
28 KiB
React
Raw Normal View History

2026-05-19 17:11:45 -04:00
// RecordingDetail: audio player, transcript, highlights
const { useState: useStateRD, useEffect: useEffectRD, useRef: useRefRD, useCallback: useCallbackRD } = React;
const API_BASE = '';
function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHighlights, initialSeekTime, onSeekHandled }) {
const [currentTime, setCurrentTime] = useStateRD(0); // seconds into recording
const [playing, setPlaying] = useStateRD(false);
const [speed, setSpeed] = useStateRD(1.0);
const [audioDuration, setAudioDuration] = useStateRD(null); // actual audio duration
2026-05-19 17:11:45 -04:00
const [activeSegId, setActiveSegId] = useStateRD(null);
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
const audioRef = useRefRD(null);
const transcriptRef = useRefRD(null);
// Use actual audio duration if available, fallback to metadata
const totalSeconds = audioDuration || r.durationMinutes * 60;
2026-05-19 17:11:45 -04:00
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Audio URL from API
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
// Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null);
const hasAutoPlayed = useRefRD(false);
// Reset state when recording changes
2026-05-19 17:11:45 -04:00
useEffectRD(() => {
if (lastRecordingId.current !== r.id) {
hasAutoPlayed.current = false;
lastRecordingId.current = r.id;
setAudioDuration(null); // Reset to use metadata until audio loads
2026-05-19 17:11:45 -04:00
}
}, [r.id]);
// Auto-play when recording opens
useEffectRD(() => {
const audio = audioRef.current;
if (!audio || hasAutoPlayed.current) return;
const doStartPlayback = () => {
audio.play().catch(() => {}); // Ignore errors (e.g., browser autoplay policy)
hasAutoPlayed.current = true;
};
// If audio is ready, start immediately; otherwise wait for loadedmetadata
if (audio.readyState >= 1) {
doStartPlayback();
} else {
audio.addEventListener('loadedmetadata', doStartPlayback, { once: true });
return () => audio.removeEventListener('loadedmetadata', doStartPlayback);
}
}, [r.id]);
// Handle seeking to initial time (from search results, highlights, etc.)
useEffectRD(() => {
if (initialSeekTime == null) return;
const audio = audioRef.current;
if (!audio) return;
const doSeek = () => {
audio.currentTime = initialSeekTime;
setCurrentTime(initialSeekTime);
if (onSeekHandled) onSeekHandled();
};
if (audio.readyState >= 1) {
doSeek();
} else {
audio.addEventListener('loadedmetadata', doSeek, { once: true });
return () => audio.removeEventListener('loadedmetadata', doSeek);
}
}, [initialSeekTime, onSeekHandled]);
// Sync playback state with audio element
useEffectRD(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
const handleEnded = () => setPlaying(false);
const handlePlay = () => setPlaying(true);
const handlePause = () => setPlaying(false);
const handleDurationChange = () => {
if (audio.duration && isFinite(audio.duration)) {
setAudioDuration(audio.duration);
}
};
2026-05-19 17:11:45 -04:00
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('loadedmetadata', handleDurationChange);
audio.addEventListener('durationchange', handleDurationChange);
// Check if duration is already available
if (audio.duration && isFinite(audio.duration)) {
setAudioDuration(audio.duration);
}
2026-05-19 17:11:45 -04:00
return () => {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('loadedmetadata', handleDurationChange);
audio.removeEventListener('durationchange', handleDurationChange);
2026-05-19 17:11:45 -04:00
};
}, []);
// Sync playback rate with speed state
useEffectRD(() => {
if (audioRef.current) {
audioRef.current.playbackRate = speed;
}
}, [speed]);
// Track active transcript segment
useEffectRD(() => {
if (!r.transcript) return;
const active = r.transcript.find(s => currentTime >= s.startSeconds && currentTime <= s.endSeconds);
setActiveSegId(active ? active.id : null);
}, [currentTime, r.transcript]);
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 seek(e) {
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const newTime = pct * totalSeconds;
if (audioRef.current) {
audioRef.current.currentTime = newTime;
}
setCurrentTime(newTime);
}
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
audio.play();
}
}
function skipTime(delta) {
const audio = audioRef.current;
if (!audio) return;
const newTime = Math.max(0, Math.min(totalSeconds, audio.currentTime + delta));
audio.currentTime = newTime;
setCurrentTime(newTime);
}
function seekToTime(secs) {
const audio = audioRef.current;
if (audio) {
audio.currentTime = secs;
}
setCurrentTime(secs);
}
const [highlightStartTime, setHighlightStartTime] = useStateRD(null);
const [highlightEndTime, setHighlightEndTime] = useStateRD(null);
// When showing add highlight, set start time to current position
useEffectRD(() => {
if (showAddHighlight && highlightStartTime === null) {
setHighlightStartTime(Math.round(currentTime));
}
}, [showAddHighlight, currentTime, highlightStartTime]);
async function addHighlight() {
const startSecs = highlightStartTime ?? Math.round(currentTime);
const endSecs = highlightEndTime; // null for point highlight
try {
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recording_id: r.id,
start_seconds: startSecs,
end_seconds: endSecs,
tag: newHighlightTag,
note: newHighlightNote || null,
}),
});
if (!res.ok) throw new Error('Failed to create highlight');
const data = await res.json();
const newH = {
id: `h-${data.id}`,
recordingId: r.id,
startSeconds: startSecs,
endSeconds: endSecs,
tag: newHighlightTag,
note: newHighlightNote,
createdAt: new Date(),
};
setAllHighlights(prev => [...prev, newH]);
} catch (err) {
console.error('Failed to create highlight:', err);
}
setShowAddHighlight(false);
setNewHighlightNote('');
setHighlightStartTime(null);
setHighlightEndTime(null);
}
async function removeHighlight(hid) {
// Extract numeric ID from "h-123" format
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
try {
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 404) {
console.error('Failed to delete highlight');
}
} catch (err) {
console.error('Failed to delete highlight:', err);
}
setAllHighlights(prev => prev.filter(h => h.id !== hid));
}
const TAGS = ['important', 'todo', 'idea', 'follow-up', 'meeting', 'personal', 'work'];
const TAG_COLORS = {
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
};
const progressPct = (currentTime / totalSeconds) * 100;
// Waveform: fake bars
const waveformBars = 120;
const waveHeights = useRefRD(null);
if (!waveHeights.current) {
const rand = (seed) => { let s = seed; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; };
const rng = rand(r.id * 42);
waveHeights.current = Array.from({ length: waveformBars }, () => 0.1 + rng() * 0.9);
}
const qualityDetails = [
{ label: 'Quality', value: r.quality, cls: `q-${r.quality}` },
{ label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz`, cls: '' },
{ label: 'Bit Depth', value: `${r.bitDepth}-bit`, cls: '' },
{ label: 'Peak', value: `${r.peakDB.toFixed(1)} dB`, cls: r.peakDB > -30 ? 'q-good' : r.peakDB > -45 ? 'q-fair' : 'q-poor' },
{ label: 'Size', value: `${r.fileSizeMB} MB`, cls: '' },
{ label: 'Source', value: r.source, cls: `source-${r.source}` },
];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12, background: 'var(--bg2)', flexShrink: 0 }}>
<button onClick={onBack} style={{ color: 'var(--text2)', fontSize: 18, padding: '2px 6px', borderRadius: 3, lineHeight: 1 }}></button>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)' }}>
{r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
<span style={{ color: 'var(--text3)', marginLeft: 10 }}>#{r.id}</span>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 3, alignItems: 'center' }}>
<span className={`source-pill source-${r.source}`}>{r.source}</span>
{qualityDetails.map(q => (
<span key={q.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
<span style={{ color: 'var(--text3)' }}>{q.label}: </span>
<span className={q.cls} style={{ color: q.cls ? undefined : 'var(--text2)' }}>{q.value}</span>
</span>
))}
</div>
</div>
<button
onClick={() => setShowAddHighlight(s => !s)}
style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
padding: '5px 12px', borderRadius: 4,
background: showAddHighlight ? 'var(--amber)' : 'var(--bg4)',
color: showAddHighlight ? 'var(--bg)' : 'var(--amber)',
border: '1px solid var(--amber)',
transition: 'all 0.12s',
}}
> Highlight</button>
</div>
{/* Add highlight bar */}
{showAddHighlight && (
<div style={{ background: 'var(--bg3)', borderBottom: '1px solid var(--border)', padding: '10px 20px', display: 'flex', flexDirection: 'column', gap: 8, flexShrink: 0 }}>
{/* Time range row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Start:</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', minWidth: 50 }}>
{formatTime(highlightStartTime ?? currentTime)}
</span>
<button
onClick={() => setHighlightStartTime(Math.round(currentTime))}
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
>Set Start</button>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginLeft: 8 }}>End:</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: highlightEndTime ? 'var(--amber)' : 'var(--text3)', minWidth: 50 }}>
{highlightEndTime ? formatTime(highlightEndTime) : '—'}
</span>
<button
onClick={() => setHighlightEndTime(Math.round(currentTime))}
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
>Set End</button>
{highlightEndTime && (
<button
onClick={() => setHighlightEndTime(null)}
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text3)' }}
>Clear</button>
)}
{highlightEndTime && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--teal)', marginLeft: 8 }}>
Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))}
</span>
)}
</div>
{/* Tag and note row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ display: 'flex', gap: 6 }}>
{TAGS.map(t => (
<button key={t} onClick={() => setNewHighlightTag(t)} style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 8px', borderRadius: 3,
background: newHighlightTag === t ? TAG_COLORS[t] : 'var(--bg4)',
color: newHighlightTag === t ? 'var(--bg)' : 'var(--text2)',
border: `1px solid ${TAG_COLORS[t] || 'var(--border)'}`,
}}>{t}</button>
))}
</div>
<input
value={newHighlightNote}
onChange={e => setNewHighlightNote(e.target.value)}
placeholder="Note (optional)…"
onKeyDown={e => e.key === 'Enter' && addHighlight()}
style={{
flex: 1, background: 'var(--bg4)', border: '1px solid var(--border)',
borderRadius: 4, padding: '4px 10px', color: 'var(--text)',
fontFamily: 'var(--font-mono)', fontSize: 11,
outline: 'none',
}}
/>
<button onClick={addHighlight} style={{
fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 12px',
background: 'var(--amber)', color: 'var(--bg)', borderRadius: 4, fontWeight: 600,
}}>Save</button>
<button onClick={() => { setShowAddHighlight(false); setHighlightStartTime(null); setHighlightEndTime(null); }} style={{ color: 'var(--text3)', fontSize: 16 }}>×</button>
</div>
</div>
)}
{/* Main: player + transcript + highlights */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Player + Transcript */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Player */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
{/* Waveform */}
<div
onClick={seek}
style={{ height: 56, display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', marginBottom: 8, position: 'relative' }}
>
{waveHeights.current.map((h, i) => {
const barPct = i / waveformBars;
const barTime = barPct * totalSeconds;
const isPast = barPct <= progressPct / 100;
const isActive = Math.abs(barPct - progressPct / 100) < 0.015;
// Check if bar is within any highlight range (or near a point highlight)
const inHighlight = recHighlights.some(hl => {
if (hl.endSeconds != null) {
// Range highlight - check if bar is within range
return barTime >= hl.startSeconds && barTime <= hl.endSeconds;
} else {
// Point highlight - check if near
return Math.abs(hl.startSeconds - barTime) < (totalSeconds / waveformBars);
}
});
// Check if bar is within the pending highlight being created
const inPendingHighlight = showAddHighlight && highlightStartTime != null && (
highlightEndTime != null
? barTime >= highlightStartTime && barTime <= highlightEndTime
: Math.abs(highlightStartTime - barTime) < (totalSeconds / waveformBars)
);
return (
<div key={i} style={{
flex: 1,
height: `${Math.round(h * 100)}%`,
background: inPendingHighlight ? 'var(--red)' : inHighlight ? 'var(--amber)' : isPast ? 'var(--teal)' : 'var(--bg4)',
borderRadius: 1,
minHeight: 2,
transition: 'background 0.05s',
opacity: isActive ? 1 : 0.85,
}} />
);
})}
{/* Highlight range markers when creating */}
{showAddHighlight && highlightStartTime != null && (
<div style={{
position: 'absolute', left: `${(highlightStartTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
width: 2, background: 'var(--red)', pointerEvents: 'none',
}} />
)}
{showAddHighlight && highlightEndTime != null && (
<div style={{
position: 'absolute', left: `${(highlightEndTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
width: 2, background: 'var(--red)', pointerEvents: 'none',
}} />
)}
{/* Playhead */}
<div style={{
position: 'absolute', left: `${progressPct}%`, top: 0, bottom: 0,
width: 2, background: 'var(--amber)', pointerEvents: 'none',
boxShadow: '0 0 6px var(--amber)',
}} />
</div>
{/* Hidden audio element */}
<audio ref={audioRef} src={audioUrl} preload="metadata" />
{/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={() => skipTime(-30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-30s</button>
<button onClick={() => skipTime(-5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-5s</button>
<button
onClick={togglePlay}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--amber)', color: 'var(--bg)',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>{playing ? '⏸' : '▶'}</button>
<button onClick={() => skipTime(5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+5s</button>
<button onClick={() => skipTime(30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+30s</button>
<div style={{ flex: 1 }} />
{/* Speed */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{[0.5, 1.0, 1.5, 2.0, 3.0].map(s => (
<button key={s} onClick={() => setSpeed(s)} style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 7px',
borderRadius: 3,
background: speed === s ? 'var(--amber)' : 'var(--bg4)',
color: speed === s ? 'var(--bg)' : 'var(--text3)',
border: '1px solid var(--border)',
}}>{s}×</button>
))}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', minWidth: 100, textAlign: 'right' }}>
{formatTime(currentTime)} / {formatTime(totalSeconds)}
</div>
</div>
</div>
{/* Transcript */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Transcript header with scroll-to-current button */}
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Transcript · {r.transcript?.length || 0} segments
</div>
<div style={{ flex: 1 }} />
{r.hasTranscript && r.transcript.length > 0 && (
<button
onClick={() => {
// Find the segment closest to current time
const el = transcriptRef.current?.querySelector(`[data-seg-id="${activeSegId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Find closest segment
const closest = r.transcript.reduce((prev, curr) =>
Math.abs(curr.startSeconds - currentTime) < Math.abs(prev.startSeconds - currentTime) ? curr : prev
);
const closestEl = transcriptRef.current?.querySelector(`[data-seg-id="${closest.id}"]`);
if (closestEl) closestEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}}
style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px',
background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4,
color: 'var(--amber)', cursor: 'pointer',
}}
>
Go to {formatTime(currentTime)}
</button>
)}
</div>
<div ref={transcriptRef} style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{!r.hasTranscript || r.transcript.length === 0 ? (
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '20px 0' }}>
No transcript available. Run speech-to-text batch job to generate.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{r.transcript.map(seg => {
const isActive = seg.id === activeSegId;
const hasHighlight = recHighlights.some(h => h.segmentId === seg.id);
return (
<div
key={seg.id}
data-seg-id={seg.id}
onClick={() => seekToTime(seg.startSeconds)}
style={{
display: 'flex', gap: 12, padding: '10px 12px', borderRadius: 5,
background: isActive ? 'var(--amber-glow)' : 'transparent',
border: `1px solid ${isActive ? 'var(--amber-dim)' : hasHighlight ? 'var(--amber-dim)' : 'transparent'}`,
cursor: 'pointer', transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg3)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: isActive ? 'var(--amber)' : 'var(--text3)' }}>
{formatTime(seg.startSeconds)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>
{Math.round(seg.confidence * 100)}%
</div>
</div>
<div style={{ flex: 1, fontFamily: 'var(--font-sans)', fontSize: 14, lineHeight: 1.6, color: isActive ? 'var(--text)' : 'var(--text2)', textWrap: 'pretty' }}>
{seg.text}
</div>
{hasHighlight && (
<div style={{ flexShrink: 0, color: 'var(--amber)', fontSize: 12, paddingTop: 2 }}></div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
{/* Right: Highlights panel */}
<div style={{ width: 260, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg2)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Highlights · {recHighlights.length}
</div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px' }}>
{recHighlights.length === 0 ? (
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '8px 4px' }}>
No highlights yet.<br />Press Highlight to mark a moment.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{recHighlights
.sort((a, b) => a.startSeconds - b.startSeconds)
.map(h => (
<HighlightCard key={h.id} highlight={h} tagColors={TAG_COLORS} onSeek={seekToTime} onRemove={removeHighlight} formatTime={formatTime} />
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
const TAG_COLORS = {
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
};
function HighlightCard({ highlight: h, tagColors, onSeek, onRemove, formatTime }) {
const color = tagColors[h.tag] || 'var(--text3)';
const hasRange = h.endSeconds != null;
return (
<div style={{
background: 'var(--bg3)', border: `1px solid ${color}44`,
borderLeft: `3px solid ${color}`, borderRadius: '0 4px 4px 0',
padding: '8px 10px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<button
onClick={() => onSeek(h.startSeconds)}
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}
>
{formatTime(h.startSeconds)}
{hasRange && `${formatTime(h.endSeconds)}`}
</button>
{hasRange && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--teal)' }}>
({formatTime(h.endSeconds - h.startSeconds)})
</span>
)}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '1px 5px', borderRadius: 3, background: `${color}22`, color }}>{h.tag}</span>
</div>
<button onClick={() => onRemove(h.id)} style={{ color: 'var(--text3)', fontSize: 13, lineHeight: 1 }}>×</button>
</div>
{h.note && (
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text2)', lineHeight: 1.5 }}>{h.note}</div>
)}
</div>
);
}
Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS });