515 lines
16 KiB
JavaScript
515 lines
16 KiB
JavaScript
// 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 />);
|