2026-05-19 17:11:45 -04:00
|
|
|
|
// Main App component
|
|
|
|
|
|
const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React;
|
|
|
|
|
|
|
|
|
|
|
|
const API_BASE = '';
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 20:38:39 -04:00
|
|
|
|
// 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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 17:11:45 -04:00
|
|
|
|
// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 17:11:45 -04:00
|
|
|
|
function App() {
|
|
|
|
|
|
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
|
|
|
|
|
|
const [loading, setLoading] = useStateApp(true);
|
|
|
|
|
|
const [error, setError] = useStateApp(null);
|
2026-05-19 20:19:02 -04:00
|
|
|
|
const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey());
|
2026-05-19 17:11:45 -04:00
|
|
|
|
|
|
|
|
|
|
// Lift highlights state so adding/removing works across views
|
|
|
|
|
|
const [highlights, setHighlights] = useStateApp([]);
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
// Listen for auth-required events (401 responses)
|
|
|
|
|
|
useEffectApp(() => {
|
|
|
|
|
|
function handleAuthRequired() {
|
|
|
|
|
|
setNeedsAuth(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('auth-required', handleAuthRequired);
|
|
|
|
|
|
return () => window.removeEventListener('auth-required', handleAuthRequired);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-19 17:11:45 -04:00
|
|
|
|
// 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 {
|
2026-05-19 20:19:02 -04:00
|
|
|
|
const res = await authFetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
|
2026-05-19 17:11:45 -04:00
|
|
|
|
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([
|
2026-05-19 20:19:02 -04:00
|
|
|
|
authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
|
|
|
|
|
|
authFetch(`${API_BASE}/api/v1/sources`),
|
|
|
|
|
|
authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
|
2026-05-19 17:11:45 -04:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-19 20:38:39 -04:00
|
|
|
|
setSelectedDayState(toLocalDateString(sorted[0].startTime));
|
2026-05-19 17:11:45 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
// 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();
|
|
|
|
|
|
}} />
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 17:11:45 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 20:19:02 -04:00
|
|
|
|
// Export utilities for other components
|
2026-05-19 20:38:39 -04:00
|
|
|
|
Object.assign(window, { authFetch, getStoredApiKey, toLocalDateString });
|
2026-05-19 20:19:02 -04:00
|
|
|
|
|
2026-05-19 17:11:45 -04:00
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
|
|
|
|
root.render(<App />);
|