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