diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25c04d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Audio/video files +*.opus +*.ogg +*.mp3 +*.wav +*.m4a +*.flac +*.webm + +# Data files +*.db +*.db-journal +*.pkl +*.csv + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Nix +result +result-* + +# Keys (NEVER commit) +*.key +keys/ +API_KEY.txt + +# IDE +.idea/ +.vscode/ +*.kate-swp +*.swp + +# Build artifacts +build/ +.gradle/ +*.apk +*.aab + +# Android SDK (huge) +android_studio_install_path/ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Test recordings +test_recordings/ +recordings/ + +# Temporary +*.tmp +*.temp +.temp/ + +# Lock files (optional - uncomment if you want to track these) +# uv.lock +# flake.lock diff --git a/clio-ui/RecordingsViewer.html b/clio-ui/RecordingsViewer.html new file mode 100644 index 0000000..30f31d4 --- /dev/null +++ b/clio-ui/RecordingsViewer.html @@ -0,0 +1,85 @@ + + + + + +RecordingsViewer + + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/clio-ui/components/.keep b/clio-ui/components/.keep new file mode 100644 index 0000000..e69de29 diff --git a/clio-ui/components/App.jsx b/clio-ui/components/App.jsx new file mode 100644 index 0000000..2673e25 --- /dev/null +++ b/clio-ui/components/App.jsx @@ -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 ( +
+ Loading recordings... +
+ ); + } + + if (error) { + return ( +
+ Error: {error} +
+ ); + } + + // 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 ( +
+ + +
+ {/* Top bar */} +
+ {view === 'detail' && selectedRecording && ( + + )} +
+
+ {new Date().toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })} +
+
+ + {/* View content */} +
+ {view === 'dashboard' && ( + + )} + {view === 'calendar' && ( + + )} + {view === 'search' && ( + + )} + {view === 'highlights' && ( + + )} + {loadingDetail && ( +
+ Loading recording... +
+ )} + {view === 'detail' && selectedRecording && !loadingDetail && ( + r.id === selectedRecording.id) || selectedRecording} + data={liveData} + onBack={() => setView('calendar')} + allHighlights={highlights} + setAllHighlights={setHighlights} + initialSeekTime={initialSeekTime} + onSeekHandled={() => setInitialSeekTime(null)} + /> + )} +
+
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/clio-ui/components/Dashboard.jsx b/clio-ui/components/Dashboard.jsx new file mode 100644 index 0000000..8233901 --- /dev/null +++ b/clio-ui/components/Dashboard.jsx @@ -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 ( +
+ + {/* Stat cards */} +
+ {[ + { 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 => ( +
+
{card.label}
+
{card.value}
+
{card.sub}
+
+ ))} +
+ + {/* Source breakdown */} +
+ {Object.entries(bySrc).map(([src, count]) => { + const srcHours = data.recordings.filter(r => r.source === src).reduce((a, r) => a + r.durationMinutes / 60, 0); + return ( +
+
+ {src} +
+
+
{count} files
+
{Math.round(srcHours)}h recorded
+
+
+ ); + })} +
+ + {/* Calendar heatmap */} +
+
Activity — Last 8 Months
+ +
+ + {/* Recent recordings */} +
+
Recent Recordings
+
+ {recent.map(r => ( + { setSelectedRecording(r); setView('detail'); }} /> + ))} +
+
+
+ ); +} + +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 ( +
+ {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(
); + } + 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( +
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 ( +
+
{label}
+
{cells}
+
+ ); + })} +
+ ); +} + +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 ( +
e.currentTarget.style.background = 'var(--bg3)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > +
{dateStr} {timeStr}
+ {r.source} +
{dur}
+
{r.quality}
+
{r.fileSizeMB} MB
+ {r.highlights.length > 0 && ( +
+ {r.highlights.length} ◈ +
+ )} + {r.hasTranscript && ( +
✦ transcript
+ )} +
+ ); +} + +Object.assign(window, { Dashboard, CalendarHeatmap, RecordingRow }); diff --git a/clio-ui/components/DayView.jsx b/clio-ui/components/DayView.jsx new file mode 100644 index 0000000..0ce3ca3 --- /dev/null +++ b/clio-ui/components/DayView.jsx @@ -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 ( +
+ + {/* Left: Month calendar */} +
+ {/* Month nav */} +
+ +
{monthLabel}
+ +
+ + {/* Weekday headers */} +
+ {['S','M','T','W','T','F','S'].map((d, i) => ( +
{d}
+ ))} + {/* Empty cells */} + {Array.from({ length: startDow }).map((_, 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 ( +
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'; }} + > + {d} + {hasRecs && ( +
+ {recs.slice(0, 3).map((_, ri) => ( +
+ ))} +
+ )} +
+ ); + })} +
+ + {/* Day summary */} + {selectedDay && dayRecs.length > 0 && ( +
+
+ {dayRecs.length} recordings · {Math.round(dayRecs.reduce((a, r) => a + r.durationMinutes, 0) / 60)}h total +
+ {['portable','stationary','phone'].map(src => { + const count = dayRecs.filter(r => r.source === src).length; + if (!count) return null; + return ( +
+ {src} + {count} +
+ ); + })} +
+ )} +
+ + {/* Right: Day timeline */} +
+ {!selectedDay ? ( +
+ Select a day to view recordings +
+ ) : ( + <> +
+ {fmtDay(selectedDay)} +
+ {dayRecs.length === 0 ? ( +
No recordings this day.
+ ) : ( + { setSelectedRecording(r); setView('detail'); }} /> + )} + + )} +
+
+ ); +} + +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 ( +
+ {/* Timeline */} +
+ {/* Hour lines + labels */} + {hours.map(h => ( +
+
+ {String(h).padStart(2, '0')}:00 +
+
+
+ ))} + + {/* 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 ( +
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`; }} + > + {r.source} + + {Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m + + {r.quality} + {r.highlights.length > 0 && ( + ◈ {r.highlights.length} + )} +
+ ); + })} +
+ + {/* Density strip - clickable to go to recording at that hour */} +
+
density
+
+ {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 ( +
recAtHour && onSelect(recAtHour)} + style={{ + height: HOUR_H, + display: 'flex', + alignItems: 'stretch', + cursor: recAtHour ? 'pointer' : 'default', + }} + > +
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, + }} /> +
+ ); + })} +
+
+ + {/* Recording list for quick jump */} +
+
Jump to
+ {recordings.map(r => { + const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return ( +
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)'; }} + > +
+ {timeStr} + {r.source} + {Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m +
+
+ {r.quality} + {r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB + {r.hasTranscript && ✦ transcript} + {r.highlights.length > 0 && ◈ {r.highlights.length}} +
+
+ ); + })} +
+
+ ); +} + +Object.assign(window, { DayView, DayTimeline }); diff --git a/clio-ui/components/HighlightsView.jsx b/clio-ui/components/HighlightsView.jsx new file mode 100644 index 0000000..9bbf291 --- /dev/null +++ b/clio-ui/components/HighlightsView.jsx @@ -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 ( +
+ {/* Left panel: tag breakdown */} +
+
+ By Tag · {highlights.length} total +
+ + {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 ( + + ); + })} +
+ + {/* Right: highlights list */} +
+
+
+ {sorted.length} highlight{sorted.length !== 1 ? 's' : ''} + {filterTag ? ` · ${filterTag}` : ''} +
+
+ Sort: + {['recent', 'recording', 'tag'].map(s => ( + + ))} +
+ +
+ {sorted.length === 0 ? ( +
+ No highlights yet.

Open a recording and press ◈ Highlight to mark moments. +
+ ) : ( +
+ {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 ( +
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; }} + > +
+ {h.tag} + {rec && ( + <> + + {rec.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' })} + + {rec.source} + + )} + + {formatTime(h.startSeconds)} + +
+ {seg && ( +
+ "{seg.text}" +
+ )} + {h.note && ( +
+ {h.note} +
+ )} +
+ ); + })} +
+ )} +
+
+
+ ); +} + +Object.assign(window, { HighlightsView }); diff --git a/clio-ui/components/RecordingDetail.jsx b/clio-ui/components/RecordingDetail.jsx new file mode 100644 index 0000000..74a55af --- /dev/null +++ b/clio-ui/components/RecordingDetail.jsx @@ -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 ( +
+ {/* Header */} +
+ +
+
+ {r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + #{r.id} +
+
+ {r.source} + {qualityDetails.map(q => ( + + {q.label}: + {q.value} + + ))} +
+
+ +
+ + {/* Add highlight bar */} + {showAddHighlight && ( +
+ {/* Time range row */} +
+ Start: + + {formatTime(highlightStartTime ?? currentTime)} + + + + End: + + {highlightEndTime ? formatTime(highlightEndTime) : '—'} + + + {highlightEndTime && ( + + )} + + {highlightEndTime && ( + + Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))} + + )} +
+ + {/* Tag and note row */} +
+
+ {TAGS.map(t => ( + + ))} +
+ 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', + }} + /> + + +
+
+ )} + + {/* Main: player + transcript + highlights */} +
+ + {/* Left: Player + Transcript */} +
+ + {/* Player */} +
+ {/* Waveform */} +
+ {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 ( +
+ ); + })} + + {/* Highlight range markers when creating */} + {showAddHighlight && highlightStartTime != null && ( +
+ )} + {showAddHighlight && highlightEndTime != null && ( +
+ )} + + {/* Playhead */} +
+
+ + {/* Hidden audio element */} +
+
+ ); +} + +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 ( +
+
+
+ + {hasRange && ( + + ({formatTime(h.endSeconds - h.startSeconds)}) + + )} + {h.tag} +
+ +
+ {h.note && ( +
{h.note}
+ )} +
+ ); +} + +Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS }); diff --git a/clio-ui/components/SearchView.jsx b/clio-ui/components/SearchView.jsx new file mode 100644 index 0000000..a9dc01b --- /dev/null +++ b/clio-ui/components/SearchView.jsx @@ -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 ( + + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ); + } + + const SOURCES = ['portable', 'stationary', 'phone']; + const QUALITIES = ['excellent', 'good', 'fair', 'poor']; + + const hasFilters = query.trim() || filterSource.length > 0 || filterQuality.length > 0 || filterHasTranscript || filterHasHighlights; + + return ( +
+ {/* Search bar */} +
+
+ + 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 && ( + + )} +
+ + {/* Filters */} +
+ Filter: + {SOURCES.map(s => ( + toggleArr(filterSource, setFilterSource, s)} /> + ))} +
+ {QUALITIES.map(q => ( + toggleArr(filterQuality, setFilterQuality, q)} /> + ))} +
+ setFilterHasTranscript(v => !v)} /> + setFilterHasHighlights(v => !v)} /> +
+
+ + {/* Results */} +
+ {!hasFilters ? ( +
+
+
Search transcripts or apply filters
+
+ ) : results.length === 0 ? ( +
No results found.
+ ) : ( + <> +
+ {results.length} recording{results.length !== 1 ? 's' : ''} · {results.reduce((a, r) => a + r.matchingSegments.length, 0)} segment matches +
+
+ {results.map(({ recording, matchingSegments }) => ( + { setSelectedRecording(recording, seekTime); setView('detail'); }} + /> + ))} +
+ + )} +
+
+ ); +} + +function FilterChip({ label, active, colorClass, onClick }) { + return ( + + ); +} + +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 ( +
+ {/* Card header - opens at first match */} +
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'} + > + {dateStr} {timeStr} + {r.source} + {dur} + {r.quality} + {r.highlights.length > 0 && ( + ◈ {r.highlights.length} + )} +
+ {matchingSegments.length > 0 && ( + {matchingSegments.length} match{matchingSegments.length !== 1 ? 'es' : ''} + )} + +
+ + {/* Matching segments - each opens at its specific time */} + {matchingSegments.slice(0, 3).map(seg => ( +
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'} + > + {formatTime(seg.startSeconds)} + + {highlightText(seg.text, query)} + +
+ ))} + {matchingSegments.length > 3 && ( +
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 →
+ )} +
+ ); +} + +Object.assign(window, { SearchView, FilterChip, SearchResultCard }); diff --git a/clio-ui/components/Sidebar.jsx b/clio-ui/components/Sidebar.jsx new file mode 100644 index 0000000..2fd1dd2 --- /dev/null +++ b/clio-ui/components/Sidebar.jsx @@ -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 ( +
+ {/* Logo */} +
+
RecordingsViewer
+
+ {totalCount} files + {totalGB} GB +
+
+ + {/* Nav */} + + + {/* Device status */} + +
+ ); +} + +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 ( +
+
Devices
+ {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 ( +
+
+
+ + {key} +
+ {fmtSince(dev.lastSeen)} +
+
{dev.model}
+
+ ); + })} +
+ ); +} + +Object.assign(window, { Sidebar, DeviceStatus }); diff --git a/clio-ui/components/Tweaks.jsx b/clio-ui/components/Tweaks.jsx new file mode 100644 index 0000000..9fd5973 --- /dev/null +++ b/clio-ui/components/Tweaks.jsx @@ -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 ( + + + setTweak('accentColor', v)} /> + setTweak('density', v)} + /> + + + setTweak('monoTimestamps', v)} /> + setTweak('showQualityColors', v)} /> + + + ); +} + +// Mount tweaks panel in a separate div +const tweaksRoot = document.createElement('div'); +tweaksRoot.id = 'tweaks-root'; +document.body.appendChild(tweaksRoot); +ReactDOM.createRoot(tweaksRoot).render(); diff --git a/clio-ui/tweaks-panel.jsx b/clio-ui/tweaks-panel.jsx new file mode 100644 index 0000000..5f8f95a --- /dev/null +++ b/clio-ui/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +});