// 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();