adding the ui

This commit is contained in:
Your Name 2026-05-19 17:11:45 -04:00
parent 14445e7242
commit eaeac63ef3
12 changed files with 2638 additions and 0 deletions

0
clio-ui/components/.keep Normal file
View file

367
clio-ui/components/App.jsx Normal file
View file

@ -0,0 +1,367 @@
// Main App component
const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React;
const API_BASE = '';
// Parse hash into route info
function parseHash() {
const hash = window.location.hash.slice(1) || '/dashboard';
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'recording' && parts[1]) {
return { view: 'detail', recordingId: parseInt(parts[1], 10) };
}
if (parts[0] === 'calendar' && parts[1]) {
return { view: 'calendar', day: parts[1] };
}
const validViews = ['dashboard', 'calendar', 'search', 'highlights'];
const view = validViews.includes(parts[0]) ? parts[0] : 'dashboard';
return { view };
}
// Build hash from route info
function buildHash(view, options = {}) {
if (view === 'detail' && options.recordingId) {
return `#/recording/${options.recordingId}`;
}
if (view === 'calendar' && options.day) {
return `#/calendar/${options.day}`;
}
return `#/${view}`;
}
// Transform API recording to frontend format
function transformRecording(rec) {
const startTime = new Date(rec.recorded_at);
const durationMinutes = (rec.duration || 0) / 60;
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000);
return {
id: rec.id,
source: rec.source_name,
quality: 'good', // API doesn't track quality, default to good
durationMinutes,
startTime,
endTime,
fileSizeMB: rec.file_size_bytes ? Math.round(rec.file_size_bytes / 1024 / 1024) : Math.round(durationMinutes * 0.9),
transcript: (rec.segments || []).map(s => ({
id: `${rec.id}-seg-${s.id}`,
startSeconds: s.start_time,
endSeconds: s.end_time,
text: s.text,
confidence: s.confidence || 0.9,
})),
hasTranscript: (rec.segments || []).length > 0,
highlights: (rec.highlights || []).map(h => ({
id: `h-${h.id}`,
recordingId: h.recording_id,
segmentId: h.segment_id,
startSeconds: h.start_seconds,
endSeconds: h.end_seconds || null,
tag: h.tag,
note: h.note || '',
createdAt: new Date(h.created_at),
})),
sampleRate: rec.sample_rate || 44100,
bitDepth: rec.bit_depth || 16,
peakDB: -30,
audioPath: rec.audio_path,
};
}
function App() {
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
const [loading, setLoading] = useStateApp(true);
const [error, setError] = useStateApp(null);
// Lift highlights state so adding/removing works across views
const [highlights, setHighlights] = useStateApp([]);
// Initialize from URL hash
const initialRoute = parseHash();
const [view, setViewState] = useStateApp(initialRoute.view);
const [selectedDay, setSelectedDayState] = useStateApp(initialRoute.day || null);
const [selectedRecording, setSelectedRecording] = useStateApp(null);
const [loadingDetail, setLoadingDetail] = useStateApp(false);
const [initialSeekTime, setInitialSeekTime] = useStateApp(null);
// Track if we should skip pushing to history (for popstate handling)
const skipHistoryPush = useRefApp(false);
// Update URL when view changes
function setView(newView) {
setViewState(newView);
if (newView !== 'detail') {
setSelectedRecording(null);
}
if (!skipHistoryPush.current) {
const hash = buildHash(newView, { day: selectedDay });
if (window.location.hash !== hash) {
window.history.pushState(null, '', hash);
}
}
}
// Update URL when selected day changes
function setSelectedDay(day) {
setSelectedDayState(day);
if (view === 'calendar' && !skipHistoryPush.current) {
const hash = buildHash('calendar', { day });
if (window.location.hash !== hash) {
window.history.pushState(null, '', hash);
}
}
}
// Set initial hash if empty
useEffectApp(() => {
if (!window.location.hash) {
window.history.replaceState(null, '', '#/dashboard');
}
}, []);
// Handle browser back/forward
useEffectApp(() => {
function handlePopState() {
skipHistoryPush.current = true;
const route = parseHash();
setViewState(route.view);
if (route.day) {
setSelectedDayState(route.day);
}
if (route.view === 'detail' && route.recordingId) {
fetchRecordingDetail(route.recordingId);
} else {
setSelectedRecording(null);
}
skipHistoryPush.current = false;
}
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Fetch full recording details (with transcript) when selecting a recording
async function fetchRecordingDetail(recordingId) {
setLoadingDetail(true);
try {
const res = await fetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
if (!res.ok) throw new Error('Failed to fetch recording');
const rec = await res.json();
const transformed = transformRecording(rec);
// Update the recording in our data
setData(prev => ({
...prev,
recordings: prev.recordings.map(r =>
r.id === recordingId ? transformed : r
),
}));
setSelectedRecording(transformed);
} catch (err) {
console.error('Failed to fetch recording detail:', err);
} finally {
setLoadingDetail(false);
}
}
// Wrapper to select recording and fetch its details
// Optional seekTime to jump to a specific position (e.g., from highlights)
function selectRecording(rec, seekTime = null) {
if (rec && rec.id) {
setInitialSeekTime(seekTime);
fetchRecordingDetail(rec.id);
setViewState('detail');
if (!skipHistoryPush.current) {
const hash = buildHash('detail', { recordingId: rec.id });
window.history.pushState(null, '', hash);
}
} else {
setSelectedRecording(null);
setInitialSeekTime(null);
}
}
// Fetch data from API
useEffectApp(() => {
async function fetchData() {
try {
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
fetch(`${API_BASE}/api/v1/sources`),
fetch(`${API_BASE}/api/v1/highlights?limit=1000`),
]);
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
const recordingsData = await recordingsRes.json();
const sourcesData = await sourcesRes.json();
const highlightsData = await highlightsRes.json();
// Transform recordings
const recordings = recordingsData.recordings.map(transformRecording);
// Transform sources to devices format
const devices = {};
for (const src of sourcesData.sources || []) {
devices[src.source_name] = {
name: src.source_name.charAt(0).toUpperCase() + src.source_name.slice(1),
lastSeen: src.last_recording ? new Date(src.last_recording) : new Date(),
model: src.source_name,
totalRecordings: src.recording_count,
totalHours: src.total_hours || 0,
};
}
// Transform highlights
const allHighlights = (highlightsData.highlights || []).map(h => ({
id: `h-${h.id}`,
recordingId: h.recording_id,
segmentId: h.segment_id,
startSeconds: h.start_seconds,
endSeconds: h.end_seconds || null,
tag: h.tag,
note: h.note || '',
createdAt: new Date(h.created_at),
}));
setData({ recordings, highlights: allHighlights, devices });
setHighlights(allHighlights);
// Handle initial URL route
const route = parseHash();
// Set selected day from URL or default to most recent
if (route.day) {
setSelectedDayState(route.day);
} else if (recordings.length > 0) {
const sorted = [...recordings].sort((a, b) => b.startTime - a.startTime);
setSelectedDayState(sorted[0].startTime.toISOString().slice(0, 10));
}
// If URL has a recording ID, fetch it
if (route.view === 'detail' && route.recordingId) {
fetchRecordingDetail(route.recordingId);
}
setLoading(false);
} catch (err) {
console.error('Failed to fetch data:', err);
setError(err.message);
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}>
Loading recordings...
</div>
);
}
if (error) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--red)' }}>
Error: {error}
</div>
);
}
// navigateTo is now just an alias for setView (which handles URL updates)
const navigateTo = setView;
// Augment data with live highlights
const liveData = {
...data,
highlights,
recordings: data.recordings.map(r => ({
...r,
highlights: highlights.filter(h => h.recordingId === r.id),
})),
};
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Sidebar view={view === 'detail' ? '_detail' : view} setView={navigateTo} data={liveData} />
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top bar */}
<div style={{
height: 40, flexShrink: 0, borderBottom: '1px solid var(--border)',
background: 'var(--bg2)', display: 'flex', alignItems: 'center',
padding: '0 20px', gap: 12,
}}>
{view === 'detail' && selectedRecording && (
<button onClick={() => setView('calendar')} style={{ color: 'var(--text3)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{selectedRecording.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' })}
</button>
)}
<div style={{ flex: 1 }} />
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
{new Date().toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
{/* View content */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{view === 'dashboard' && (
<Dashboard
data={liveData}
setView={navigateTo}
setSelectedDay={setSelectedDay}
setSelectedRecording={selectRecording}
/>
)}
{view === 'calendar' && (
<DayView
data={liveData}
selectedDay={selectedDay}
setSelectedDay={setSelectedDay}
setSelectedRecording={selectRecording}
setView={navigateTo}
/>
)}
{view === 'search' && (
<SearchView
data={liveData}
setSelectedRecording={selectRecording}
setView={navigateTo}
/>
)}
{view === 'highlights' && (
<HighlightsView
data={liveData}
highlights={highlights}
setSelectedRecording={selectRecording}
setView={navigateTo}
/>
)}
{loadingDetail && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', color: 'var(--text2)' }}>
Loading recording...
</div>
)}
{view === 'detail' && selectedRecording && !loadingDetail && (
<RecordingDetail
recording={liveData.recordings.find(r => r.id === selectedRecording.id) || selectedRecording}
data={liveData}
onBack={() => setView('calendar')}
allHighlights={highlights}
setAllHighlights={setHighlights}
initialSeekTime={initialSeekTime}
onSeekHandled={() => setInitialSeekTime(null)}
/>
)}
</div>
</main>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View file

@ -0,0 +1,213 @@
// Dashboard view: stats, calendar heatmap, recent recordings
function Dashboard({ data, setView, setSelectedDay, setSelectedRecording }) {
const now = new Date();
// Aggregate stats
const totalHours = data.recordings.reduce((a, r) => a + r.durationMinutes / 60, 0);
const totalGB = data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024;
const transcribedCount = data.recordings.filter(r => r.hasTranscript).length;
const highlightCount = data.highlights.length;
// By source
const bySrc = {};
for (const r of data.recordings) {
bySrc[r.source] = (bySrc[r.source] || 0) + 1;
}
// Calendar heatmap: last 8 months
const calStart = new Date(now);
calStart.setMonth(calStart.getMonth() - 7);
calStart.setDate(1);
// Build day map
const dayMap = {};
for (const r of data.recordings) {
const key = r.startTime.toISOString().slice(0, 10);
if (!dayMap[key]) dayMap[key] = { count: 0, minutes: 0 };
dayMap[key].count++;
dayMap[key].minutes += r.durationMinutes;
}
const maxMinutes = Math.max(...Object.values(dayMap).map(d => d.minutes), 1);
// Recent recordings
const recent = [...data.recordings]
.sort((a, b) => b.startTime - a.startTime)
.slice(0, 8);
function handleDayClick(dateStr) {
setSelectedDay(dateStr);
setView('calendar');
}
function fmtDuration(min) {
const h = Math.floor(min / 60);
const m = min % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function qualityClass(q) {
return `q-${q}`;
}
return (
<div style={{ flex: 1, overflow: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* Stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
{[
{ label: 'Recordings', value: data.recordings.length.toLocaleString(), sub: 'total files' },
{ label: 'Total Duration', value: `${Math.round(totalHours)}h`, sub: `${Math.round(totalHours / 24)} days of audio` },
{ label: 'Storage Used', value: `${totalGB.toFixed(1)} GB`, sub: `avg ${(totalGB / data.recordings.length * 1024).toFixed(0)} MB/file` },
{ label: 'Highlights', value: highlightCount, sub: `${transcribedCount} transcribed` },
].map(card => (
<div key={card.label} style={{
background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 6, padding: '16px 18px',
}}>
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>{card.label}</div>
<div style={{ fontSize: 28, fontFamily: 'var(--font-mono)', fontWeight: 500, color: 'var(--amber)', lineHeight: 1 }}>{card.value}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 5, fontFamily: 'var(--font-mono)' }}>{card.sub}</div>
</div>
))}
</div>
{/* Source breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{Object.entries(bySrc).map(([src, count]) => {
const srcHours = data.recordings.filter(r => r.source === src).reduce((a, r) => a + r.durationMinutes / 60, 0);
return (
<div key={src} style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div>
<span className={`source-pill source-${src}`}>{src}</span>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, color: 'var(--text)', fontWeight: 500 }}>{count} <span style={{ fontSize: 12, color: 'var(--text3)' }}>files</span></div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{Math.round(srcHours)}h recorded</div>
</div>
</div>
);
})}
</div>
{/* Calendar heatmap */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Activity Last 8 Months</div>
<CalendarHeatmap dayMap={dayMap} maxMinutes={maxMinutes} onDayClick={handleDayClick} />
</div>
{/* Recent recordings */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Recent Recordings</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recent.map(r => (
<RecordingRow key={r.id} recording={r} onClick={() => { setSelectedRecording(r); setView('detail'); }} />
))}
</div>
</div>
</div>
);
}
function CalendarHeatmap({ dayMap, maxMinutes, onDayClick }) {
const now = new Date();
const months = [];
for (let m = 7; m >= 0; m--) {
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
months.push({ year: d.getFullYear(), month: d.getMonth(), label: d.toLocaleString('default', { month: 'short' }) + ' ' + d.getFullYear() });
}
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function intensityColor(minutes) {
if (!minutes) return 'var(--bg4)';
const t = Math.min(minutes / maxMinutes, 1);
if (t < 0.2) return 'oklch(0.35 0.08 65)';
if (t < 0.5) return 'oklch(0.50 0.11 65)';
if (t < 0.8) return 'oklch(0.65 0.13 65)';
return 'oklch(0.75 0.15 65)';
}
return (
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', paddingBottom: 4 }}>
{months.map(({ year, month, label }) => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDow = firstDay.getDay(); // 0=Sun
const daysInMonth = lastDay.getDate();
const cells = [];
// Empty cells for offset
for (let i = 0; i < startDow; i++) {
cells.push(<div key={`empty-${i}`} style={{ width: 14, height: 14 }} />);
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const info = dayMap[dateStr];
const isToday = dateStr === new Date().toISOString().slice(0, 10);
cells.push(
<div
key={d}
onClick={() => info && onDayClick(dateStr)}
title={info ? `${dateStr}: ${info.count} recordings, ${Math.round(info.minutes / 60)}h` : dateStr}
style={{
width: 14, height: 14, borderRadius: 2,
background: intensityColor(info?.minutes),
cursor: info ? 'pointer' : 'default',
outline: isToday ? '2px solid var(--amber)' : 'none',
outlineOffset: 1,
transition: 'transform 0.1s',
}}
onMouseEnter={e => { if (info) e.target.style.transform = 'scale(1.3)'; }}
onMouseLeave={e => { e.target.style.transform = 'scale(1)'; }}
/>
);
}
return (
<div key={label} style={{ flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginBottom: 6, whiteSpace: 'nowrap' }}>{label}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 14px)', gap: 3 }}>{cells}</div>
</div>
);
})}
</div>
);
}
function RecordingRow({ recording, onClick }) {
const r = recording;
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' });
const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
return (
<div
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '9px 12px', borderRadius: 4, cursor: 'pointer',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', width: 80, flexShrink: 0 }}>{dateStr} {timeStr}</div>
<span className={`source-pill source-${r.source}`}>{r.source}</span>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', width: 60, flexShrink: 0 }}>{dur}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, flexShrink: 0 }} className={`q-${r.quality}`}>{r.quality}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', flexShrink: 0 }}>{r.fileSizeMB} MB</div>
{r.highlights.length > 0 && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>
{r.highlights.length}
</div>
)}
{r.hasTranscript && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}> transcript</div>
)}
</div>
);
}
Object.assign(window, { Dashboard, CalendarHeatmap, RecordingRow });

View file

@ -0,0 +1,337 @@
// DayView: calendar month grid + day timeline with audio density visualization
const { useState: useStateDV, useEffect: useEffectDV, useRef: useRefDV } = React;
function DayView({ data, selectedDay, setSelectedDay, setSelectedRecording, setView }) {
const now = new Date();
const [viewMonth, setViewMonth] = useStateDV(() => {
if (selectedDay) {
const d = new Date(selectedDay);
return { year: d.getFullYear(), month: d.getMonth() };
}
return { year: now.getFullYear(), month: now.getMonth() };
});
// Build day map
const dayMap = {};
for (const r of data.recordings) {
const key = r.startTime.toISOString().slice(0, 10);
if (!dayMap[key]) dayMap[key] = [];
dayMap[key].push(r);
}
const maxCount = Math.max(...Object.values(dayMap).map(d => d.length), 1);
// Calendar grid
const { year, month } = viewMonth;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDow = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const prevMonth = () => {
setViewMonth(m => {
const d = new Date(m.year, m.month - 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
};
const nextMonth = () => {
setViewMonth(m => {
const d = new Date(m.year, m.month + 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
};
const monthLabel = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
const dayRecs = selectedDay ? (dayMap[selectedDay] || []).sort((a, b) => a.startTime - b.startTime) : [];
function fmtDay(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Month calendar */}
<div style={{
width: 300, flexShrink: 0, borderRight: '1px solid var(--border)',
background: 'var(--bg2)', display: 'flex', flexDirection: 'column',
padding: '20px 16px', gap: 16, overflow: 'auto',
}}>
{/* Month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button onClick={prevMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}></button>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)', fontWeight: 500 }}>{monthLabel}</div>
<button onClick={nextMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}></button>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{['S','M','T','W','T','F','S'].map((d, i) => (
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'center', paddingBottom: 4 }}>{d}</div>
))}
{/* Empty cells */}
{Array.from({ length: startDow }).map((_, i) => <div key={`e${i}`} />)}
{/* Day cells */}
{Array.from({ length: daysInMonth }).map((_, i) => {
const d = i + 1;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const recs = dayMap[dateStr] || [];
const isSelected = selectedDay === dateStr;
const isToday = dateStr === now.toISOString().slice(0, 10);
const hasRecs = recs.length > 0;
const intensity = hasRecs ? recs.length / maxCount : 0;
return (
<div
key={d}
onClick={() => hasRecs && setSelectedDay(dateStr)}
style={{
position: 'relative',
aspectRatio: '1',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
borderRadius: 4,
cursor: hasRecs ? 'pointer' : 'default',
background: isSelected ? 'var(--amber)' : isToday ? 'var(--bg4)' : 'transparent',
outline: isToday && !isSelected ? '1px solid var(--border)' : 'none',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (hasRecs && !isSelected) e.currentTarget.style.background = 'var(--bg4)'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isToday ? 'var(--bg4)' : 'transparent'; }}
>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
color: isSelected ? 'var(--bg)' : isToday ? 'var(--amber)' : hasRecs ? 'var(--text)' : 'var(--text3)',
fontWeight: isSelected || isToday ? 600 : 400,
lineHeight: 1,
}}>{d}</span>
{hasRecs && (
<div style={{
position: 'absolute', bottom: 3,
display: 'flex', gap: 1, justifyContent: 'center',
}}>
{recs.slice(0, 3).map((_, ri) => (
<div key={ri} style={{
width: 3, height: 3, borderRadius: '50%',
background: isSelected ? 'var(--bg)' : 'var(--amber)',
opacity: 0.8,
}} />
))}
</div>
)}
</div>
);
})}
</div>
{/* Day summary */}
{selectedDay && dayRecs.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
{dayRecs.length} recordings · {Math.round(dayRecs.reduce((a, r) => a + r.durationMinutes, 0) / 60)}h total
</div>
{['portable','stationary','phone'].map(src => {
const count = dayRecs.filter(r => r.source === src).length;
if (!count) return null;
return (
<div key={src} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span className={`source-pill source-${src}`}>{src}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{count}</span>
</div>
);
})}
</div>
)}
</div>
{/* Right: Day timeline */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
{!selectedDay ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>
Select a day to view recordings
</div>
) : (
<>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', marginBottom: 6 }}>
{fmtDay(selectedDay)}
</div>
{dayRecs.length === 0 ? (
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, marginTop: 20 }}>No recordings this day.</div>
) : (
<DayTimeline recordings={dayRecs} onSelect={r => { setSelectedRecording(r); setView('detail'); }} />
)}
</>
)}
</div>
</div>
);
}
function DayTimeline({ recordings, onSelect }) {
// 24-hour timeline with recording bars
const HOUR_H = 52;
const LABEL_W = 44;
const BAR_W = 260;
const hours = Array.from({ length: 24 }, (_, i) => i);
function recTop(r) {
const h = r.startTime.getHours() + r.startTime.getMinutes() / 60;
return h * HOUR_H;
}
function recHeight(r) {
return Math.max((r.durationMinutes / 60) * HOUR_H, 8);
}
function qualityColor(q) {
const map = { excellent: 'var(--green)', good: 'var(--teal)', fair: 'var(--amber)', poor: 'var(--red)' };
return map[q] || 'var(--text3)';
}
const totalH = 24 * HOUR_H;
// Density mini-bars per hour (minutes of audio per hour)
const densityPerHour = Array(24).fill(0);
for (const r of recordings) {
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
const endH = startH + r.durationMinutes / 60;
for (let h = 0; h < 24; h++) {
const overlapStart = Math.max(h, startH);
const overlapEnd = Math.min(h + 1, endH);
if (overlapEnd > overlapStart) {
densityPerHour[h] += (overlapEnd - overlapStart) * 60;
}
}
}
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginTop: 12 }}>
{/* Timeline */}
<div style={{ position: 'relative', height: totalH, width: LABEL_W + BAR_W + 32, flexShrink: 0 }}>
{/* Hour lines + labels */}
{hours.map(h => (
<div key={h} style={{ position: 'absolute', top: h * HOUR_H, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: LABEL_W, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'right', flexShrink: 0, paddingTop: 1 }}>
{String(h).padStart(2, '0')}:00
</div>
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginTop: 6 }} />
</div>
))}
{/* Recording bars */}
{recordings.map(r => {
const top = recTop(r);
const height = recHeight(r);
const srcColors = { portable: 'var(--amber)', stationary: 'var(--teal)', phone: 'var(--green)' };
const srcColor = srcColors[r.source] || 'var(--text3)';
return (
<div
key={r.id}
onClick={() => onSelect(r)}
title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`}
style={{
position: 'absolute',
top: top + 1,
left: LABEL_W + 12,
width: BAR_W,
height: height - 2,
background: `${srcColor}22`,
border: `1px solid ${srcColor}66`,
borderLeft: `3px solid ${srcColor}`,
borderRadius: '0 4px 4px 0',
cursor: 'pointer',
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 8px',
overflow: 'hidden',
transition: 'background 0.1s',
zIndex: 1,
}}
onMouseEnter={e => { e.currentTarget.style.background = `${srcColor}40`; }}
onMouseLeave={e => { e.currentTarget.style.background = `${srcColor}22`; }}
>
<span className={`source-pill source-${r.source}`} style={{ fontSize: 9, padding: '1px 5px', flexShrink: 0 }}>{r.source}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}>
{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }} className={`q-${r.quality}`}>{r.quality}</span>
{r.highlights.length > 0 && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>
)}
</div>
);
})}
</div>
{/* Density strip - clickable to go to recording at that hour */}
<div style={{ flexShrink: 0, width: 40 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4, writingMode: 'horizontal-tb' }}>density</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{densityPerHour.map((mins, h) => {
const pct = mins / 60;
// Find recording that covers this hour
const recAtHour = recordings.find(r => {
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
const endH = startH + r.durationMinutes / 60;
return h >= startH && h < endH;
});
return (
<div
key={h}
onClick={() => recAtHour && onSelect(recAtHour)}
style={{
height: HOUR_H,
display: 'flex',
alignItems: 'stretch',
cursor: recAtHour ? 'pointer' : 'default',
}}
>
<div style={{
width: Math.round(pct * 36) + 'px',
minWidth: pct > 0 ? 2 : 0,
background: pct > 0.8 ? 'var(--amber)' : pct > 0.3 ? 'var(--amber-dim)' : 'var(--bg4)',
transition: 'width 0.2s, opacity 0.1s',
opacity: recAtHour ? 1 : 0.5,
}} />
</div>
);
})}
</div>
</div>
{/* Recording list for quick jump */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Jump to</div>
{recordings.map(r => {
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<div
key={r.id}
onClick={() => onSelect(r)}
style={{
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4,
padding: '8px 12px', cursor: 'pointer', transition: 'border-color 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--amber)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>{timeStr}</span>
<span className={`source-pill source-${r.source}`}>{r.source}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m</span>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }}>{r.quality}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>{r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB</span>
{r.hasTranscript && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}> transcript</span>}
{r.highlights.length > 0 && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>}
</div>
</div>
);
})}
</div>
</div>
);
}
Object.assign(window, { DayView, DayTimeline });

View file

@ -0,0 +1,171 @@
// HighlightsView: all saved highlights across all recordings
const { useState: useStateHV, useMemo: useMemoHV } = React;
function HighlightsView({ data, highlights, setSelectedRecording, setView }) {
const [filterTag, setFilterTag] = useStateHV('');
const [sortBy, setSortBy] = useStateHV('recent');
const allTags = [...new Set(highlights.map(h => h.tag))].sort();
const sorted = useMemoHV(() => {
let hs = [...highlights];
if (filterTag) hs = hs.filter(h => h.tag === filterTag);
if (sortBy === 'recent') hs.sort((a, b) => b.createdAt - a.createdAt);
else if (sortBy === 'recording') hs.sort((a, b) => {
const ra = data.recordings.find(r => r.id === a.recordingId);
const rb = data.recordings.find(r => r.id === b.recordingId);
return (rb?.startTime || 0) - (ra?.startTime || 0);
});
else if (sortBy === 'tag') hs.sort((a, b) => a.tag.localeCompare(b.tag));
return hs;
}, [highlights, filterTag, sortBy, data]);
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 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 openRecording(highlight) {
const rec = data.recordings.find(r => r.id === highlight.recordingId);
if (rec) {
// Pass the highlight's startSeconds to seek to that position
setSelectedRecording(rec, highlight.startSeconds);
setView('detail');
}
}
// Group by tag for summary
const tagCounts = {};
for (const h of highlights) tagCounts[h.tag] = (tagCounts[h.tag] || 0) + 1;
return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left panel: tag breakdown */}
<div style={{ width: 200, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--bg2)', padding: '18px 14px', overflow: 'auto' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12 }}>
By Tag · {highlights.length} total
</div>
<button
onClick={() => setFilterTag('')}
style={{
width: '100%', textAlign: 'left', padding: '7px 10px', borderRadius: 4,
fontFamily: 'var(--font-mono)', fontSize: 11,
background: !filterTag ? 'var(--amber-glow)' : 'transparent',
color: !filterTag ? 'var(--amber)' : 'var(--text2)',
borderLeft: !filterTag ? '2px solid var(--amber)' : '2px solid transparent',
marginBottom: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
>
<span>All</span>
<span style={{ color: 'var(--text3)' }}>{highlights.length}</span>
</button>
{Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => {
const color = TAG_COLORS[tag] || 'var(--text3)';
const isActive = filterTag === tag;
return (
<button
key={tag}
onClick={() => setFilterTag(tag)}
style={{
width: '100%', textAlign: 'left', padding: '7px 10px', borderRadius: 4,
fontFamily: 'var(--font-mono)', fontSize: 11,
background: isActive ? `${color}18` : 'transparent',
color: isActive ? color : 'var(--text2)',
borderLeft: isActive ? `2px solid ${color}` : '2px solid transparent',
marginBottom: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
transition: 'all 0.1s',
}}
>
<span>{tag}</span>
<span style={{ color: 'var(--text3)' }}>{count}</span>
</button>
);
})}
</div>
{/* Right: highlights list */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>
{sorted.length} highlight{sorted.length !== 1 ? 's' : ''}
{filterTag ? ` · ${filterTag}` : ''}
</div>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Sort:</span>
{['recent', 'recording', 'tag'].map(s => (
<button key={s} onClick={() => setSortBy(s)} style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 9px', borderRadius: 3,
background: sortBy === s ? 'var(--bg4)' : 'transparent',
color: sortBy === s ? 'var(--amber)' : 'var(--text3)',
border: `1px solid ${sortBy === s ? 'var(--amber)' : 'var(--border)'}`,
}}>{s}</button>
))}
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '14px 20px' }}>
{sorted.length === 0 ? (
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: '40px 0', textAlign: 'center' }}>
No highlights yet.<br /><br />Open a recording and press Highlight to mark moments.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 10 }}>
{sorted.map(h => {
const rec = data.recordings.find(r => r.id === h.recordingId);
const color = TAG_COLORS[h.tag] || 'var(--text3)';
const seg = rec?.transcript?.find(s => s.id === h.segmentId);
return (
<div
key={h.id}
onClick={() => openRecording(h)}
style={{
background: 'var(--bg2)', border: `1px solid var(--border)`,
borderLeft: `3px solid ${color}`, borderRadius: '0 6px 6px 0',
padding: '12px 14px', cursor: 'pointer', transition: 'border-color 0.1s, background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.borderColor = color; }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg2)'; e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.borderLeftColor = color; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '1px 6px', borderRadius: 3, background: `${color}22`, color }}>{h.tag}</span>
{rec && (
<>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
{rec.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' })}
</span>
<span className={`source-pill source-${rec.source}`} style={{ fontSize: 9 }}>{rec.source}</span>
</>
)}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', marginLeft: 'auto' }}>
{formatTime(h.startSeconds)}
</span>
</div>
{seg && (
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, marginBottom: h.note ? 6 : 0, textWrap: 'pretty' }}>
"{seg.text}"
</div>
)}
{h.note && (
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text3)', fontStyle: 'italic' }}>
{h.note}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
Object.assign(window, { HighlightsView });

View file

@ -0,0 +1,611 @@
// 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 [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);
const totalSeconds = r.durationMinutes * 60;
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 auto-play flag when recording changes
useEffectRD(() => {
if (lastRecordingId.current !== r.id) {
hasAutoPlayed.current = false;
lastRecordingId.current = r.id;
}
}, [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);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
return () => {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
};
}, []);
// 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 });

View file

@ -0,0 +1,212 @@
// 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); setView('detail'); }}
/>
))}
</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 });

View file

@ -0,0 +1,99 @@
// Sidebar navigation component
const NAV_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', icon: '▦' },
{ id: 'calendar', label: 'Calendar', icon: '◫' },
{ id: 'search', label: 'Search', icon: '⌕' },
{ id: 'highlights', label: 'Highlights', icon: '◈' },
];
function Sidebar({ view, setView, data }) {
const totalGB = (data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024).toFixed(1);
const totalCount = data.recordings.length;
return (
<div style={{
width: 'var(--sidebar-w)', flexShrink: 0,
background: 'var(--bg2)', borderRight: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', height: '100%',
}}>
{/* Logo */}
<div style={{ padding: '18px 16px 14px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 4 }}>RecordingsViewer</div>
<div style={{ display: 'flex', gap: 12, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>
<span><span style={{ color: 'var(--amber)' }}>{totalCount}</span> files</span>
<span><span style={{ color: 'var(--amber)' }}>{totalGB}</span> GB</span>
</div>
</div>
{/* Nav */}
<nav style={{ flex: 1, padding: '10px 0' }}>
{NAV_ITEMS.map(item => (
<button
key={item.id}
onClick={() => setView(item.id)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 16px', textAlign: 'left',
background: view === item.id ? 'var(--amber-glow)' : 'none',
color: view === item.id ? 'var(--amber)' : 'var(--text2)',
borderLeft: view === item.id ? '2px solid var(--amber)' : '2px solid transparent',
fontSize: 13, fontWeight: view === item.id ? 500 : 400,
transition: 'all 0.12s',
}}
>
<span style={{ fontSize: 15, fontFamily: 'var(--font-mono)', opacity: 0.8 }}>{item.icon}</span>
{item.label}
</button>
))}
</nav>
{/* Device status */}
<DeviceStatus devices={data.devices} />
</div>
);
}
function DeviceStatus({ devices }) {
const now = new Date();
function hoursSince(date) {
return (now - date) / (1000 * 60 * 60);
}
function fmtSince(date) {
const h = hoursSince(date);
if (h < 1) return `${Math.round(h * 60)}m ago`;
if (h < 24) return `${Math.round(h)}h ago`;
return `${Math.round(h / 24)}d ago`;
}
return (
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px 16px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Devices</div>
{Object.entries(devices).map(([key, dev]) => {
const hours = hoursSince(dev.lastSeen);
const bad = hours > 24;
const color = bad ? 'var(--red)' : 'var(--green)';
const dotColor = bad ? 'var(--red)' : 'var(--green)';
return (
<div key={key} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 6, height: 6, borderRadius: '50%', background: dotColor,
boxShadow: bad ? 'none' : `0 0 4px ${dotColor}`,
flexShrink: 0, display: 'inline-block',
}}></span>
<span style={{ fontSize: 12, color: 'var(--text)', textTransform: 'capitalize' }}>{key}</span>
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color }}>{fmtSince(dev.lastSeen)}</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', paddingLeft: 12 }}>{dev.model}</div>
</div>
);
})}
</div>
);
}
Object.assign(window, { Sidebar, DeviceStatus });

View file

@ -0,0 +1,52 @@
// Tweaks panel for RecordingsViewer
// Loaded after tweaks-panel.jsx
function TweaksApp() {
const { TweaksPanel, TweakSection, TweakToggle, TweakRadio, TweakColor, useTweaks } = window;
const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
"accentColor": "oklch(0.75 0.15 65)",
"density": "normal",
"monoTimestamps": true,
"showQualityColors": true,
"sidebarCollapsed": false
}/*EDITMODE-END*/);
// Apply tweaks to CSS vars live
React.useEffect(() => {
const root = document.documentElement;
// Accent color
root.style.setProperty('--amber', tweaks.accentColor);
// Density
const pad = tweaks.density === 'compact' ? '6px 10px' : tweaks.density === 'comfortable' ? '14px 18px' : '9px 12px';
root.style.setProperty('--row-padding', pad);
// Monospace timestamps
document.querySelectorAll('.mono-ts').forEach(el => {
el.style.fontFamily = tweaks.monoTimestamps ? 'var(--font-mono)' : 'var(--font-sans)';
});
}, [tweaks]);
return (
<TweaksPanel>
<TweakSection label="Appearance">
<TweakColor label="Accent color" value={tweaks.accentColor} onChange={v => setTweak('accentColor', v)} />
<TweakRadio
label="Density"
value={tweaks.density}
options={[{ label: 'Compact', value: 'compact' }, { label: 'Normal', value: 'normal' }, { label: 'Airy', value: 'comfortable' }]}
onChange={v => setTweak('density', v)}
/>
</TweakSection>
<TweakSection label="Display">
<TweakToggle label="Monospace timestamps" value={tweaks.monoTimestamps} onChange={v => setTweak('monoTimestamps', v)} />
<TweakToggle label="Quality color coding" value={tweaks.showQualityColors} onChange={v => setTweak('showQualityColors', v)} />
</TweakSection>
</TweaksPanel>
);
}
// Mount tweaks panel in a separate div
const tweaksRoot = document.createElement('div');
tweaksRoot.id = 'tweaks-root';
document.body.appendChild(tweaksRoot);
ReactDOM.createRoot(tweaksRoot).render(<TweaksApp />);