368 lines
12 KiB
React
368 lines
12 KiB
React
|
|
// 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 />);
|