clio/clio-ui/components/RecordingDetail.jsx
2026-05-19 20:19:02 -04:00

665 lines
29 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.

// 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
const [activeSegId, setActiveSegId] = useStateRD(null);
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
const [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
const [audioLoading, setAudioLoading] = useStateRD(true);
const audioRef = useRefRD(null);
const transcriptRef = useRefRD(null);
// Use actual audio duration if available, fallback to metadata
const metadataDuration = r.durationMinutes * 60;
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Fetch audio with auth and create blob URL
useEffectRD(() => {
let cancelled = false;
let blobUrl = null;
async function fetchAudio() {
setAudioLoading(true);
try {
const res = await window.authFetch(`${API_BASE}/api/v1/recordings/${r.id}/audio`);
if (cancelled) return;
if (!res.ok) throw new Error('Failed to fetch audio');
const blob = await res.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl(blobUrl);
} catch (err) {
console.error('Failed to load audio:', err);
} finally {
if (!cancelled) setAudioLoading(false);
}
}
fetchAudio();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [r.id]);
// Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null);
const hasAutoPlayed = useRefRD(false);
// Reset state when recording changes
useEffectRD(() => {
if (lastRecordingId.current !== r.id) {
hasAutoPlayed.current = false;
lastRecordingId.current = r.id;
setAudioDuration(null); // Reset to use metadata until audio loads
setAudioBlobUrl(null); // Clear old blob URL
}
}, [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);
}
};
const handleLoadedMetadata = () => {
if (audio.duration && isFinite(audio.duration)) {
setAudioDuration(audio.duration);
}
};
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('durationchange', handleDurationChange);
// Check if duration is already available
if (audio.duration && isFinite(audio.duration)) {
setAudioDuration(audio.duration);
}
return () => {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('durationchange', handleDurationChange);
};
}, [r.id]); // Re-attach listeners when recording changes
// 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 window.authFetch(`${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 window.authFetch(`${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 */}
{audioBlobUrl && <audio ref={audioRef} src={audioBlobUrl} preload="metadata" />}
{audioLoading && <div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Loading audio...</div>}
{/* 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 });