clio/clio-ui/components/App.jsx

368 lines
12 KiB
React
Raw Normal View History

2026-05-19 17:11:45 -04:00
// 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 />);