adding the ui
This commit is contained in:
parent
14445e7242
commit
eaeac63ef3
12 changed files with 2638 additions and 0 deletions
367
clio-ui/components/App.jsx
Normal file
367
clio-ui/components/App.jsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// Main App component
|
||||
const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React;
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
// 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 App() {
|
||||
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
|
||||
const [loading, setLoading] = useStateApp(true);
|
||||
const [error, setError] = useStateApp(null);
|
||||
|
||||
// Lift highlights state so adding/removing works across views
|
||||
const [highlights, setHighlights] = useStateApp([]);
|
||||
|
||||
// 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 fetch(`${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([
|
||||
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
|
||||
fetch(`${API_BASE}/api/v1/sources`),
|
||||
fetch(`${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(sorted[0].startTime.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
Loading…
Add table
Add a link
Reference in a new issue