// Main App component const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React; const API_BASE = ''; // API key storage const API_KEY_STORAGE_KEY = 'clio_api_key'; function getStoredApiKey() { return localStorage.getItem(API_KEY_STORAGE_KEY); } function setStoredApiKey(key) { localStorage.setItem(API_KEY_STORAGE_KEY, key); } function clearStoredApiKey() { localStorage.removeItem(API_KEY_STORAGE_KEY); } // Get local date string in YYYY-MM-DD format (not UTC) function toLocalDateString(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } // Authenticated fetch wrapper async function authFetch(url, options = {}) { const apiKey = getStoredApiKey(); const headers = { ...options.headers, 'X-API-Key': apiKey || '', }; const response = await fetch(url, { ...options, headers }); if (response.status === 401) { clearStoredApiKey(); window.dispatchEvent(new Event('auth-required')); } return response; } // 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 ApiKeyPrompt({ onSubmit }) { const [key, setKey] = useStateApp(''); const [error, setError] = useStateApp(null); const [checking, setChecking] = useStateApp(false); async function handleSubmit(e) { e.preventDefault(); if (!key.trim()) return; setChecking(true); setError(null); try { // Test the key by making a simple request const res = await fetch(`${API_BASE}/api/v1/sources`, { headers: { 'X-API-Key': key.trim() } }); if (res.status === 401) { setError('Invalid API key'); setChecking(false); return; } if (!res.ok) { setError('Server error'); setChecking(false); return; } setStoredApiKey(key.trim()); onSubmit(); } catch (err) { setError('Connection failed'); setChecking(false); } } return (
Enter API Key
setKey(e.target.value)} placeholder="API Key" autoFocus style={{ width: '100%', padding: '10px 12px', marginBottom: 12, background: 'var(--bg3)', border: '1px solid var(--border)', borderRadius: 4, color: 'var(--text)', fontFamily: 'var(--font-mono)', fontSize: 13, outline: 'none', }} /> {error && (
{error}
)}
); } function App() { const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} }); const [loading, setLoading] = useStateApp(true); const [error, setError] = useStateApp(null); const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey()); // Lift highlights state so adding/removing works across views const [highlights, setHighlights] = useStateApp([]); // Listen for auth-required events (401 responses) useEffectApp(() => { function handleAuthRequired() { setNeedsAuth(true); } window.addEventListener('auth-required', handleAuthRequired); return () => window.removeEventListener('auth-required', handleAuthRequired); }, []); // 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 authFetch(`${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([ authFetch(`${API_BASE}/api/v1/recordings?limit=1000`), authFetch(`${API_BASE}/api/v1/sources`), authFetch(`${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(toLocalDateString(sorted[0].startTime)); } // 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(); }, []); // Show API key prompt if not authenticated if (needsAuth) { return ( { setNeedsAuth(false); setLoading(true); // Re-trigger data fetch by incrementing a counter or similar // For simplicity, just reload the page window.location.reload(); }} /> ); } 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)} /> )}
); } // Export utilities for other components Object.assign(window, { authFetch, getStoredApiKey, toLocalDateString }); const root = ReactDOM.createRoot(document.getElementById('root')); root.render();