clio/clio-ui/components/App.jsx
Your Name aa5741eafb
All checks were successful
Deploy UI / deploy (push) Successful in 1s
icon
2026-05-19 20:54:48 -04:00

515 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: 'var(--bg)',
}}>
<form onSubmit={handleSubmit} style={{
background: 'var(--bg2)', padding: 32, borderRadius: 8,
border: '1px solid var(--border)', width: 320,
}}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, marginBottom: 20, color: 'var(--text)' }}>
Enter API Key
</div>
<input
type="password"
value={key}
onChange={(e) => 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 && (
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
{error}
</div>
)}
<button
type="submit"
disabled={checking}
style={{
width: '100%', padding: '10px 12px',
background: 'var(--amber)', color: 'var(--bg)',
border: 'none', borderRadius: 4, fontFamily: 'var(--font-mono)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
opacity: checking ? 0.6 : 1,
}}
>
{checking ? 'Checking...' : 'Continue'}
</button>
</form>
</div>
);
}
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 (
<ApiKeyPrompt onSubmit={() => {
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 (
<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>
);
}
// Export utilities for other components
Object.assign(window, { authFetch, getStoredApiKey, toLocalDateString });
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);