adding the ui
This commit is contained in:
parent
14445e7242
commit
eaeac63ef3
12 changed files with 2638 additions and 0 deletions
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Audio/video files
|
||||||
|
*.opus
|
||||||
|
*.ogg
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.m4a
|
||||||
|
*.flac
|
||||||
|
*.webm
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.pkl
|
||||||
|
*.csv
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# Keys (NEVER commit)
|
||||||
|
*.key
|
||||||
|
keys/
|
||||||
|
API_KEY.txt
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.kate-swp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Android SDK (huge)
|
||||||
|
android_studio_install_path/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test recordings
|
||||||
|
test_recordings/
|
||||||
|
recordings/
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.temp/
|
||||||
|
|
||||||
|
# Lock files (optional - uncomment if you want to track these)
|
||||||
|
# uv.lock
|
||||||
|
# flake.lock
|
||||||
85
clio-ui/RecordingsViewer.html
Normal file
85
clio-ui/RecordingsViewer.html
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RecordingsViewer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,300&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script src="mock-data.js"></script>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: oklch(0.12 0.01 60);
|
||||||
|
--bg2: oklch(0.15 0.01 60);
|
||||||
|
--bg3: oklch(0.18 0.012 60);
|
||||||
|
--bg4: oklch(0.22 0.012 60);
|
||||||
|
--border: oklch(0.28 0.01 60);
|
||||||
|
--text: oklch(0.88 0.01 65);
|
||||||
|
--text2: oklch(0.65 0.01 65);
|
||||||
|
--text3: oklch(0.45 0.01 65);
|
||||||
|
--amber: oklch(0.75 0.15 65);
|
||||||
|
--amber-dim: oklch(0.55 0.10 65);
|
||||||
|
--amber-glow: oklch(0.75 0.15 65 / 0.15);
|
||||||
|
--teal: oklch(0.65 0.10 195);
|
||||||
|
--teal-dim: oklch(0.45 0.08 195);
|
||||||
|
--red: oklch(0.60 0.18 25);
|
||||||
|
--red-dim: oklch(0.40 0.12 25);
|
||||||
|
--green: oklch(0.62 0.14 155);
|
||||||
|
--green-dim: oklch(0.42 0.09 155);
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
--font-sans: 'IBM Plex Sans', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 14px; overflow: hidden; }
|
||||||
|
|
||||||
|
#root { height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text3); }
|
||||||
|
|
||||||
|
button { font-family: var(--font-sans); cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
input, textarea { font-family: var(--font-sans); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Quality badge colors */
|
||||||
|
.q-excellent { color: var(--green); }
|
||||||
|
.q-good { color: var(--teal); }
|
||||||
|
.q-fair { color: var(--amber); }
|
||||||
|
.q-poor { color: var(--red); }
|
||||||
|
|
||||||
|
/* Source pill */
|
||||||
|
.source-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 2px 7px; border-radius: 3px;
|
||||||
|
background: var(--bg4); color: var(--text2); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.source-portable { border-color: var(--amber-dim); color: var(--amber); }
|
||||||
|
.source-stationary { border-color: var(--teal-dim); color: var(--teal); }
|
||||||
|
.source-phone { border-color: var(--green-dim); color: var(--green); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script type="text/babel" src="components/Sidebar.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/Dashboard.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/DayView.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/RecordingDetail.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/SearchView.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/HighlightsView.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/App.jsx"></script>
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="components/Tweaks.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
clio-ui/components/.keep
Normal file
0
clio-ui/components/.keep
Normal file
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 />);
|
||||||
213
clio-ui/components/Dashboard.jsx
Normal file
213
clio-ui/components/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Dashboard view: stats, calendar heatmap, recent recordings
|
||||||
|
function Dashboard({ data, setView, setSelectedDay, setSelectedRecording }) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Aggregate stats
|
||||||
|
const totalHours = data.recordings.reduce((a, r) => a + r.durationMinutes / 60, 0);
|
||||||
|
const totalGB = data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024;
|
||||||
|
const transcribedCount = data.recordings.filter(r => r.hasTranscript).length;
|
||||||
|
const highlightCount = data.highlights.length;
|
||||||
|
|
||||||
|
// By source
|
||||||
|
const bySrc = {};
|
||||||
|
for (const r of data.recordings) {
|
||||||
|
bySrc[r.source] = (bySrc[r.source] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar heatmap: last 8 months
|
||||||
|
const calStart = new Date(now);
|
||||||
|
calStart.setMonth(calStart.getMonth() - 7);
|
||||||
|
calStart.setDate(1);
|
||||||
|
|
||||||
|
// Build day map
|
||||||
|
const dayMap = {};
|
||||||
|
for (const r of data.recordings) {
|
||||||
|
const key = r.startTime.toISOString().slice(0, 10);
|
||||||
|
if (!dayMap[key]) dayMap[key] = { count: 0, minutes: 0 };
|
||||||
|
dayMap[key].count++;
|
||||||
|
dayMap[key].minutes += r.durationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMinutes = Math.max(...Object.values(dayMap).map(d => d.minutes), 1);
|
||||||
|
|
||||||
|
// Recent recordings
|
||||||
|
const recent = [...data.recordings]
|
||||||
|
.sort((a, b) => b.startTime - a.startTime)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
function handleDayClick(dateStr) {
|
||||||
|
setSelectedDay(dateStr);
|
||||||
|
setView('calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(min) {
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityClass(q) {
|
||||||
|
return `q-${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Recordings', value: data.recordings.length.toLocaleString(), sub: 'total files' },
|
||||||
|
{ label: 'Total Duration', value: `${Math.round(totalHours)}h`, sub: `${Math.round(totalHours / 24)} days of audio` },
|
||||||
|
{ label: 'Storage Used', value: `${totalGB.toFixed(1)} GB`, sub: `avg ${(totalGB / data.recordings.length * 1024).toFixed(0)} MB/file` },
|
||||||
|
{ label: 'Highlights', value: highlightCount, sub: `${transcribedCount} transcribed` },
|
||||||
|
].map(card => (
|
||||||
|
<div key={card.label} style={{
|
||||||
|
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6, padding: '16px 18px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>{card.label}</div>
|
||||||
|
<div style={{ fontSize: 28, fontFamily: 'var(--font-mono)', fontWeight: 500, color: 'var(--amber)', lineHeight: 1 }}>{card.value}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 5, fontFamily: 'var(--font-mono)' }}>{card.sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source breakdown */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
{Object.entries(bySrc).map(([src, count]) => {
|
||||||
|
const srcHours = data.recordings.filter(r => r.source === src).reduce((a, r) => a + r.durationMinutes / 60, 0);
|
||||||
|
return (
|
||||||
|
<div key={src} style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<span className={`source-pill source-${src}`}>{src}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, color: 'var(--text)', fontWeight: 500 }}>{count} <span style={{ fontSize: 12, color: 'var(--text3)' }}>files</span></div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{Math.round(srcHours)}h recorded</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar heatmap */}
|
||||||
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Activity — Last 8 Months</div>
|
||||||
|
<CalendarHeatmap dayMap={dayMap} maxMinutes={maxMinutes} onDayClick={handleDayClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent recordings */}
|
||||||
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Recent Recordings</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{recent.map(r => (
|
||||||
|
<RecordingRow key={r.id} recording={r} onClick={() => { setSelectedRecording(r); setView('detail'); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarHeatmap({ dayMap, maxMinutes, onDayClick }) {
|
||||||
|
const now = new Date();
|
||||||
|
const months = [];
|
||||||
|
|
||||||
|
for (let m = 7; m >= 0; m--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
|
||||||
|
months.push({ year: d.getFullYear(), month: d.getMonth(), label: d.toLocaleString('default', { month: 'short' }) + ' ' + d.getFullYear() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function intensityColor(minutes) {
|
||||||
|
if (!minutes) return 'var(--bg4)';
|
||||||
|
const t = Math.min(minutes / maxMinutes, 1);
|
||||||
|
if (t < 0.2) return 'oklch(0.35 0.08 65)';
|
||||||
|
if (t < 0.5) return 'oklch(0.50 0.11 65)';
|
||||||
|
if (t < 0.8) return 'oklch(0.65 0.13 65)';
|
||||||
|
return 'oklch(0.75 0.15 65)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', paddingBottom: 4 }}>
|
||||||
|
{months.map(({ year, month, label }) => {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const startDow = firstDay.getDay(); // 0=Sun
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
// Empty cells for offset
|
||||||
|
for (let i = 0; i < startDow; i++) {
|
||||||
|
cells.push(<div key={`empty-${i}`} style={{ width: 14, height: 14 }} />);
|
||||||
|
}
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const info = dayMap[dateStr];
|
||||||
|
const isToday = dateStr === new Date().toISOString().slice(0, 10);
|
||||||
|
cells.push(
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
onClick={() => info && onDayClick(dateStr)}
|
||||||
|
title={info ? `${dateStr}: ${info.count} recordings, ${Math.round(info.minutes / 60)}h` : dateStr}
|
||||||
|
style={{
|
||||||
|
width: 14, height: 14, borderRadius: 2,
|
||||||
|
background: intensityColor(info?.minutes),
|
||||||
|
cursor: info ? 'pointer' : 'default',
|
||||||
|
outline: isToday ? '2px solid var(--amber)' : 'none',
|
||||||
|
outlineOffset: 1,
|
||||||
|
transition: 'transform 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (info) e.target.style.transform = 'scale(1.3)'; }}
|
||||||
|
onMouseLeave={e => { e.target.style.transform = 'scale(1)'; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={label} style={{ flexShrink: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginBottom: 6, whiteSpace: 'nowrap' }}>{label}</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 14px)', gap: 3 }}>{cells}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingRow({ recording, onClick }) {
|
||||||
|
const r = recording;
|
||||||
|
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '9px 12px', borderRadius: 4, cursor: 'pointer',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', width: 80, flexShrink: 0 }}>{dateStr} {timeStr}</div>
|
||||||
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', width: 60, flexShrink: 0 }}>{dur}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, flexShrink: 0 }} className={`q-${r.quality}`}>{r.quality}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', flexShrink: 0 }}>{r.fileSizeMB} MB</div>
|
||||||
|
{r.highlights.length > 0 && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>
|
||||||
|
{r.highlights.length} ◈
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.hasTranscript && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>✦ transcript</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Dashboard, CalendarHeatmap, RecordingRow });
|
||||||
337
clio-ui/components/DayView.jsx
Normal file
337
clio-ui/components/DayView.jsx
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
// DayView: calendar month grid + day timeline with audio density visualization
|
||||||
|
const { useState: useStateDV, useEffect: useEffectDV, useRef: useRefDV } = React;
|
||||||
|
|
||||||
|
function DayView({ data, selectedDay, setSelectedDay, setSelectedRecording, setView }) {
|
||||||
|
const now = new Date();
|
||||||
|
const [viewMonth, setViewMonth] = useStateDV(() => {
|
||||||
|
if (selectedDay) {
|
||||||
|
const d = new Date(selectedDay);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
}
|
||||||
|
return { year: now.getFullYear(), month: now.getMonth() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build day map
|
||||||
|
const dayMap = {};
|
||||||
|
for (const r of data.recordings) {
|
||||||
|
const key = r.startTime.toISOString().slice(0, 10);
|
||||||
|
if (!dayMap[key]) dayMap[key] = [];
|
||||||
|
dayMap[key].push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCount = Math.max(...Object.values(dayMap).map(d => d.length), 1);
|
||||||
|
|
||||||
|
// Calendar grid
|
||||||
|
const { year, month } = viewMonth;
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const startDow = firstDay.getDay();
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
setViewMonth(m => {
|
||||||
|
const d = new Date(m.year, m.month - 1, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const nextMonth = () => {
|
||||||
|
setViewMonth(m => {
|
||||||
|
const d = new Date(m.year, m.month + 1, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthLabel = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
const dayRecs = selectedDay ? (dayMap[selectedDay] || []).sort((a, b) => a.startTime - b.startTime) : [];
|
||||||
|
|
||||||
|
function fmtDay(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left: Month calendar */}
|
||||||
|
<div style={{
|
||||||
|
width: 300, flexShrink: 0, borderRight: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg2)', display: 'flex', flexDirection: 'column',
|
||||||
|
padding: '20px 16px', gap: 16, overflow: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Month nav */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<button onClick={prevMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}>‹</button>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)', fontWeight: 500 }}>{monthLabel}</div>
|
||||||
|
<button onClick={nextMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}>›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||||
|
{['S','M','T','W','T','F','S'].map((d, i) => (
|
||||||
|
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'center', paddingBottom: 4 }}>{d}</div>
|
||||||
|
))}
|
||||||
|
{/* Empty cells */}
|
||||||
|
{Array.from({ length: startDow }).map((_, i) => <div key={`e${i}`} />)}
|
||||||
|
{/* Day cells */}
|
||||||
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||||
|
const d = i + 1;
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const recs = dayMap[dateStr] || [];
|
||||||
|
const isSelected = selectedDay === dateStr;
|
||||||
|
const isToday = dateStr === now.toISOString().slice(0, 10);
|
||||||
|
const hasRecs = recs.length > 0;
|
||||||
|
const intensity = hasRecs ? recs.length / maxCount : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
onClick={() => hasRecs && setSelectedDay(dateStr)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: hasRecs ? 'pointer' : 'default',
|
||||||
|
background: isSelected ? 'var(--amber)' : isToday ? 'var(--bg4)' : 'transparent',
|
||||||
|
outline: isToday && !isSelected ? '1px solid var(--border)' : 'none',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (hasRecs && !isSelected) e.currentTarget.style.background = 'var(--bg4)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isToday ? 'var(--bg4)' : 'transparent'; }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: isSelected ? 'var(--bg)' : isToday ? 'var(--amber)' : hasRecs ? 'var(--text)' : 'var(--text3)',
|
||||||
|
fontWeight: isSelected || isToday ? 600 : 400,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>{d}</span>
|
||||||
|
{hasRecs && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 3,
|
||||||
|
display: 'flex', gap: 1, justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{recs.slice(0, 3).map((_, ri) => (
|
||||||
|
<div key={ri} style={{
|
||||||
|
width: 3, height: 3, borderRadius: '50%',
|
||||||
|
background: isSelected ? 'var(--bg)' : 'var(--amber)',
|
||||||
|
opacity: 0.8,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day summary */}
|
||||||
|
{selectedDay && dayRecs.length > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||||
|
{dayRecs.length} recordings · {Math.round(dayRecs.reduce((a, r) => a + r.durationMinutes, 0) / 60)}h total
|
||||||
|
</div>
|
||||||
|
{['portable','stationary','phone'].map(src => {
|
||||||
|
const count = dayRecs.filter(r => r.source === src).length;
|
||||||
|
if (!count) return null;
|
||||||
|
return (
|
||||||
|
<div key={src} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span className={`source-pill source-${src}`}>{src}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Day timeline */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||||
|
{!selectedDay ? (
|
||||||
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
||||||
|
Select a day to view recordings
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', marginBottom: 6 }}>
|
||||||
|
{fmtDay(selectedDay)}
|
||||||
|
</div>
|
||||||
|
{dayRecs.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, marginTop: 20 }}>No recordings this day.</div>
|
||||||
|
) : (
|
||||||
|
<DayTimeline recordings={dayRecs} onSelect={r => { setSelectedRecording(r); setView('detail'); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayTimeline({ recordings, onSelect }) {
|
||||||
|
// 24-hour timeline with recording bars
|
||||||
|
const HOUR_H = 52;
|
||||||
|
const LABEL_W = 44;
|
||||||
|
const BAR_W = 260;
|
||||||
|
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
function recTop(r) {
|
||||||
|
const h = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||||
|
return h * HOUR_H;
|
||||||
|
}
|
||||||
|
function recHeight(r) {
|
||||||
|
return Math.max((r.durationMinutes / 60) * HOUR_H, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityColor(q) {
|
||||||
|
const map = { excellent: 'var(--green)', good: 'var(--teal)', fair: 'var(--amber)', poor: 'var(--red)' };
|
||||||
|
return map[q] || 'var(--text3)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalH = 24 * HOUR_H;
|
||||||
|
|
||||||
|
// Density mini-bars per hour (minutes of audio per hour)
|
||||||
|
const densityPerHour = Array(24).fill(0);
|
||||||
|
for (const r of recordings) {
|
||||||
|
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||||
|
const endH = startH + r.durationMinutes / 60;
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
const overlapStart = Math.max(h, startH);
|
||||||
|
const overlapEnd = Math.min(h + 1, endH);
|
||||||
|
if (overlapEnd > overlapStart) {
|
||||||
|
densityPerHour[h] += (overlapEnd - overlapStart) * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginTop: 12 }}>
|
||||||
|
{/* Timeline */}
|
||||||
|
<div style={{ position: 'relative', height: totalH, width: LABEL_W + BAR_W + 32, flexShrink: 0 }}>
|
||||||
|
{/* Hour lines + labels */}
|
||||||
|
{hours.map(h => (
|
||||||
|
<div key={h} style={{ position: 'absolute', top: h * HOUR_H, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<div style={{ width: LABEL_W, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'right', flexShrink: 0, paddingTop: 1 }}>
|
||||||
|
{String(h).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginTop: 6 }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Recording bars */}
|
||||||
|
{recordings.map(r => {
|
||||||
|
const top = recTop(r);
|
||||||
|
const height = recHeight(r);
|
||||||
|
const srcColors = { portable: 'var(--amber)', stationary: 'var(--teal)', phone: 'var(--green)' };
|
||||||
|
const srcColor = srcColors[r.source] || 'var(--text3)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => onSelect(r)}
|
||||||
|
title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: top + 1,
|
||||||
|
left: LABEL_W + 12,
|
||||||
|
width: BAR_W,
|
||||||
|
height: height - 2,
|
||||||
|
background: `${srcColor}22`,
|
||||||
|
border: `1px solid ${srcColor}66`,
|
||||||
|
borderLeft: `3px solid ${srcColor}`,
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = `${srcColor}40`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = `${srcColor}22`; }}
|
||||||
|
>
|
||||||
|
<span className={`source-pill source-${r.source}`} style={{ fontSize: 9, padding: '1px 5px', flexShrink: 0 }}>{r.source}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}>
|
||||||
|
{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }} className={`q-${r.quality}`}>{r.quality}</span>
|
||||||
|
{r.highlights.length > 0 && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Density strip - clickable to go to recording at that hour */}
|
||||||
|
<div style={{ flexShrink: 0, width: 40 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4, writingMode: 'horizontal-tb' }}>density</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{densityPerHour.map((mins, h) => {
|
||||||
|
const pct = mins / 60;
|
||||||
|
// Find recording that covers this hour
|
||||||
|
const recAtHour = recordings.find(r => {
|
||||||
|
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||||
|
const endH = startH + r.durationMinutes / 60;
|
||||||
|
return h >= startH && h < endH;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
onClick={() => recAtHour && onSelect(recAtHour)}
|
||||||
|
style={{
|
||||||
|
height: HOUR_H,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
cursor: recAtHour ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: Math.round(pct * 36) + 'px',
|
||||||
|
minWidth: pct > 0 ? 2 : 0,
|
||||||
|
background: pct > 0.8 ? 'var(--amber)' : pct > 0.3 ? 'var(--amber-dim)' : 'var(--bg4)',
|
||||||
|
transition: 'width 0.2s, opacity 0.1s',
|
||||||
|
opacity: recAtHour ? 1 : 0.5,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recording list for quick jump */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Jump to</div>
|
||||||
|
{recordings.map(r => {
|
||||||
|
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => onSelect(r)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4,
|
||||||
|
padding: '8px 12px', cursor: 'pointer', transition: 'border-color 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--amber)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>{timeStr}</span>
|
||||||
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }}>{r.quality}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>{r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB</span>
|
||||||
|
{r.hasTranscript && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>✦ transcript</span>}
|
||||||
|
{r.highlights.length > 0 && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { DayView, DayTimeline });
|
||||||
171
clio-ui/components/HighlightsView.jsx
Normal file
171
clio-ui/components/HighlightsView.jsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// HighlightsView: all saved highlights across all recordings
|
||||||
|
const { useState: useStateHV, useMemo: useMemoHV } = React;
|
||||||
|
|
||||||
|
function HighlightsView({ data, highlights, setSelectedRecording, setView }) {
|
||||||
|
const [filterTag, setFilterTag] = useStateHV('');
|
||||||
|
const [sortBy, setSortBy] = useStateHV('recent');
|
||||||
|
|
||||||
|
const allTags = [...new Set(highlights.map(h => h.tag))].sort();
|
||||||
|
|
||||||
|
const sorted = useMemoHV(() => {
|
||||||
|
let hs = [...highlights];
|
||||||
|
if (filterTag) hs = hs.filter(h => h.tag === filterTag);
|
||||||
|
if (sortBy === 'recent') hs.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
else if (sortBy === 'recording') hs.sort((a, b) => {
|
||||||
|
const ra = data.recordings.find(r => r.id === a.recordingId);
|
||||||
|
const rb = data.recordings.find(r => r.id === b.recordingId);
|
||||||
|
return (rb?.startTime || 0) - (ra?.startTime || 0);
|
||||||
|
});
|
||||||
|
else if (sortBy === 'tag') hs.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||||
|
return hs;
|
||||||
|
}, [highlights, filterTag, sortBy, data]);
|
||||||
|
|
||||||
|
const TAG_COLORS = {
|
||||||
|
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
|
||||||
|
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(secs) {
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecording(highlight) {
|
||||||
|
const rec = data.recordings.find(r => r.id === highlight.recordingId);
|
||||||
|
if (rec) {
|
||||||
|
// Pass the highlight's startSeconds to seek to that position
|
||||||
|
setSelectedRecording(rec, highlight.startSeconds);
|
||||||
|
setView('detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by tag for summary
|
||||||
|
const tagCounts = {};
|
||||||
|
for (const h of highlights) tagCounts[h.tag] = (tagCounts[h.tag] || 0) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{/* Left panel: tag breakdown */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--bg2)', padding: '18px 14px', overflow: 'auto' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12 }}>
|
||||||
|
By Tag · {highlights.length} total
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterTag('')}
|
||||||
|
style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '7px 10px', borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
background: !filterTag ? 'var(--amber-glow)' : 'transparent',
|
||||||
|
color: !filterTag ? 'var(--amber)' : 'var(--text2)',
|
||||||
|
borderLeft: !filterTag ? '2px solid var(--amber)' : '2px solid transparent',
|
||||||
|
marginBottom: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>All</span>
|
||||||
|
<span style={{ color: 'var(--text3)' }}>{highlights.length}</span>
|
||||||
|
</button>
|
||||||
|
{Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => {
|
||||||
|
const color = TAG_COLORS[tag] || 'var(--text3)';
|
||||||
|
const isActive = filterTag === tag;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setFilterTag(tag)}
|
||||||
|
style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '7px 10px', borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
background: isActive ? `${color}18` : 'transparent',
|
||||||
|
color: isActive ? color : 'var(--text2)',
|
||||||
|
borderLeft: isActive ? `2px solid ${color}` : '2px solid transparent',
|
||||||
|
marginBottom: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
transition: 'all 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<span style={{ color: 'var(--text3)' }}>{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: highlights list */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>
|
||||||
|
{sorted.length} highlight{sorted.length !== 1 ? 's' : ''}
|
||||||
|
{filterTag ? ` · ${filterTag}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Sort:</span>
|
||||||
|
{['recent', 'recording', 'tag'].map(s => (
|
||||||
|
<button key={s} onClick={() => setSortBy(s)} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 9px', borderRadius: 3,
|
||||||
|
background: sortBy === s ? 'var(--bg4)' : 'transparent',
|
||||||
|
color: sortBy === s ? 'var(--amber)' : 'var(--text3)',
|
||||||
|
border: `1px solid ${sortBy === s ? 'var(--amber)' : 'var(--border)'}`,
|
||||||
|
}}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '14px 20px' }}>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: '40px 0', textAlign: 'center' }}>
|
||||||
|
No highlights yet.<br /><br />Open a recording and press ◈ Highlight to mark moments.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 10 }}>
|
||||||
|
{sorted.map(h => {
|
||||||
|
const rec = data.recordings.find(r => r.id === h.recordingId);
|
||||||
|
const color = TAG_COLORS[h.tag] || 'var(--text3)';
|
||||||
|
const seg = rec?.transcript?.find(s => s.id === h.segmentId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h.id}
|
||||||
|
onClick={() => openRecording(h)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg2)', border: `1px solid var(--border)`,
|
||||||
|
borderLeft: `3px solid ${color}`, borderRadius: '0 6px 6px 0',
|
||||||
|
padding: '12px 14px', cursor: 'pointer', transition: 'border-color 0.1s, background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.borderColor = color; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg2)'; e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.borderLeftColor = color; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '1px 6px', borderRadius: 3, background: `${color}22`, color }}>{h.tag}</span>
|
||||||
|
{rec && (
|
||||||
|
<>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
|
||||||
|
{rec.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
<span className={`source-pill source-${rec.source}`} style={{ fontSize: 9 }}>{rec.source}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', marginLeft: 'auto' }}>
|
||||||
|
{formatTime(h.startSeconds)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{seg && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, marginBottom: h.note ? 6 : 0, textWrap: 'pretty' }}>
|
||||||
|
"{seg.text}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{h.note && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text3)', fontStyle: 'italic' }}>
|
||||||
|
{h.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { HighlightsView });
|
||||||
611
clio-ui/components/RecordingDetail.jsx
Normal file
611
clio-ui/components/RecordingDetail.jsx
Normal file
|
|
@ -0,0 +1,611 @@
|
||||||
|
// RecordingDetail: audio player, transcript, highlights
|
||||||
|
const { useState: useStateRD, useEffect: useEffectRD, useRef: useRefRD, useCallback: useCallbackRD } = React;
|
||||||
|
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHighlights, initialSeekTime, onSeekHandled }) {
|
||||||
|
const [currentTime, setCurrentTime] = useStateRD(0); // seconds into recording
|
||||||
|
const [playing, setPlaying] = useStateRD(false);
|
||||||
|
const [speed, setSpeed] = useStateRD(1.0);
|
||||||
|
const [activeSegId, setActiveSegId] = useStateRD(null);
|
||||||
|
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
|
||||||
|
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
|
||||||
|
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
|
||||||
|
const audioRef = useRefRD(null);
|
||||||
|
const transcriptRef = useRefRD(null);
|
||||||
|
|
||||||
|
const totalSeconds = r.durationMinutes * 60;
|
||||||
|
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
|
||||||
|
|
||||||
|
// Audio URL from API
|
||||||
|
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
|
||||||
|
|
||||||
|
// Track recording ID to detect when recording changes
|
||||||
|
const lastRecordingId = useRefRD(null);
|
||||||
|
const hasAutoPlayed = useRefRD(false);
|
||||||
|
|
||||||
|
// Reset auto-play flag when recording changes
|
||||||
|
useEffectRD(() => {
|
||||||
|
if (lastRecordingId.current !== r.id) {
|
||||||
|
hasAutoPlayed.current = false;
|
||||||
|
lastRecordingId.current = r.id;
|
||||||
|
}
|
||||||
|
}, [r.id]);
|
||||||
|
|
||||||
|
// Auto-play when recording opens
|
||||||
|
useEffectRD(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio || hasAutoPlayed.current) return;
|
||||||
|
|
||||||
|
const doStartPlayback = () => {
|
||||||
|
audio.play().catch(() => {}); // Ignore errors (e.g., browser autoplay policy)
|
||||||
|
hasAutoPlayed.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If audio is ready, start immediately; otherwise wait for loadedmetadata
|
||||||
|
if (audio.readyState >= 1) {
|
||||||
|
doStartPlayback();
|
||||||
|
} else {
|
||||||
|
audio.addEventListener('loadedmetadata', doStartPlayback, { once: true });
|
||||||
|
return () => audio.removeEventListener('loadedmetadata', doStartPlayback);
|
||||||
|
}
|
||||||
|
}, [r.id]);
|
||||||
|
|
||||||
|
// Handle seeking to initial time (from search results, highlights, etc.)
|
||||||
|
useEffectRD(() => {
|
||||||
|
if (initialSeekTime == null) return;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const doSeek = () => {
|
||||||
|
audio.currentTime = initialSeekTime;
|
||||||
|
setCurrentTime(initialSeekTime);
|
||||||
|
if (onSeekHandled) onSeekHandled();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (audio.readyState >= 1) {
|
||||||
|
doSeek();
|
||||||
|
} else {
|
||||||
|
audio.addEventListener('loadedmetadata', doSeek, { once: true });
|
||||||
|
return () => audio.removeEventListener('loadedmetadata', doSeek);
|
||||||
|
}
|
||||||
|
}, [initialSeekTime, onSeekHandled]);
|
||||||
|
|
||||||
|
// Sync playback state with audio element
|
||||||
|
useEffectRD(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||||
|
const handleEnded = () => setPlaying(false);
|
||||||
|
const handlePlay = () => setPlaying(true);
|
||||||
|
const handlePause = () => setPlaying(false);
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync playback rate with speed state
|
||||||
|
useEffectRD(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = speed;
|
||||||
|
}
|
||||||
|
}, [speed]);
|
||||||
|
|
||||||
|
// Track active transcript segment
|
||||||
|
useEffectRD(() => {
|
||||||
|
if (!r.transcript) return;
|
||||||
|
const active = r.transcript.find(s => currentTime >= s.startSeconds && currentTime <= s.endSeconds);
|
||||||
|
setActiveSegId(active ? active.id : null);
|
||||||
|
}, [currentTime, r.transcript]);
|
||||||
|
|
||||||
|
function formatTime(secs) {
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(e) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width;
|
||||||
|
const newTime = pct * totalSeconds;
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (playing) {
|
||||||
|
audio.pause();
|
||||||
|
} else {
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipTime(delta) {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
const newTime = Math.max(0, Math.min(totalSeconds, audio.currentTime + delta));
|
||||||
|
audio.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToTime(secs) {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = secs;
|
||||||
|
}
|
||||||
|
setCurrentTime(secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [highlightStartTime, setHighlightStartTime] = useStateRD(null);
|
||||||
|
const [highlightEndTime, setHighlightEndTime] = useStateRD(null);
|
||||||
|
|
||||||
|
// When showing add highlight, set start time to current position
|
||||||
|
useEffectRD(() => {
|
||||||
|
if (showAddHighlight && highlightStartTime === null) {
|
||||||
|
setHighlightStartTime(Math.round(currentTime));
|
||||||
|
}
|
||||||
|
}, [showAddHighlight, currentTime, highlightStartTime]);
|
||||||
|
|
||||||
|
async function addHighlight() {
|
||||||
|
const startSecs = highlightStartTime ?? Math.round(currentTime);
|
||||||
|
const endSecs = highlightEndTime; // null for point highlight
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recording_id: r.id,
|
||||||
|
start_seconds: startSecs,
|
||||||
|
end_seconds: endSecs,
|
||||||
|
tag: newHighlightTag,
|
||||||
|
note: newHighlightNote || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to create highlight');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const newH = {
|
||||||
|
id: `h-${data.id}`,
|
||||||
|
recordingId: r.id,
|
||||||
|
startSeconds: startSecs,
|
||||||
|
endSeconds: endSecs,
|
||||||
|
tag: newHighlightTag,
|
||||||
|
note: newHighlightNote,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
setAllHighlights(prev => [...prev, newH]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create highlight:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAddHighlight(false);
|
||||||
|
setNewHighlightNote('');
|
||||||
|
setHighlightStartTime(null);
|
||||||
|
setHighlightEndTime(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeHighlight(hid) {
|
||||||
|
// Extract numeric ID from "h-123" format
|
||||||
|
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
console.error('Failed to delete highlight');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete highlight:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllHighlights(prev => prev.filter(h => h.id !== hid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAGS = ['important', 'todo', 'idea', 'follow-up', 'meeting', 'personal', 'work'];
|
||||||
|
const TAG_COLORS = {
|
||||||
|
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
|
||||||
|
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPct = (currentTime / totalSeconds) * 100;
|
||||||
|
|
||||||
|
// Waveform: fake bars
|
||||||
|
const waveformBars = 120;
|
||||||
|
const waveHeights = useRefRD(null);
|
||||||
|
if (!waveHeights.current) {
|
||||||
|
const rand = (seed) => { let s = seed; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; };
|
||||||
|
const rng = rand(r.id * 42);
|
||||||
|
waveHeights.current = Array.from({ length: waveformBars }, () => 0.1 + rng() * 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityDetails = [
|
||||||
|
{ label: 'Quality', value: r.quality, cls: `q-${r.quality}` },
|
||||||
|
{ label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz`, cls: '' },
|
||||||
|
{ label: 'Bit Depth', value: `${r.bitDepth}-bit`, cls: '' },
|
||||||
|
{ label: 'Peak', value: `${r.peakDB.toFixed(1)} dB`, cls: r.peakDB > -30 ? 'q-good' : r.peakDB > -45 ? 'q-fair' : 'q-poor' },
|
||||||
|
{ label: 'Size', value: `${r.fileSizeMB} MB`, cls: '' },
|
||||||
|
{ label: 'Source', value: r.source, cls: `source-${r.source}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ borderBottom: '1px solid var(--border)', padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12, background: 'var(--bg2)', flexShrink: 0 }}>
|
||||||
|
<button onClick={onBack} style={{ color: 'var(--text2)', fontSize: 18, padding: '2px 6px', borderRadius: 3, lineHeight: 1 }}>‹</button>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)' }}>
|
||||||
|
{r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
<span style={{ color: 'var(--text3)', marginLeft: 10 }}>#{r.id}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 3, alignItems: 'center' }}>
|
||||||
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||||
|
{qualityDetails.map(q => (
|
||||||
|
<span key={q.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
|
||||||
|
<span style={{ color: 'var(--text3)' }}>{q.label}: </span>
|
||||||
|
<span className={q.cls} style={{ color: q.cls ? undefined : 'var(--text2)' }}>{q.value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddHighlight(s => !s)}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
padding: '5px 12px', borderRadius: 4,
|
||||||
|
background: showAddHighlight ? 'var(--amber)' : 'var(--bg4)',
|
||||||
|
color: showAddHighlight ? 'var(--bg)' : 'var(--amber)',
|
||||||
|
border: '1px solid var(--amber)',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
>◈ Highlight</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add highlight bar */}
|
||||||
|
{showAddHighlight && (
|
||||||
|
<div style={{ background: 'var(--bg3)', borderBottom: '1px solid var(--border)', padding: '10px 20px', display: 'flex', flexDirection: 'column', gap: 8, flexShrink: 0 }}>
|
||||||
|
{/* Time range row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>Start:</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', minWidth: 50 }}>
|
||||||
|
{formatTime(highlightStartTime ?? currentTime)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setHighlightStartTime(Math.round(currentTime))}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
|
||||||
|
>Set Start</button>
|
||||||
|
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginLeft: 8 }}>End:</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: highlightEndTime ? 'var(--amber)' : 'var(--text3)', minWidth: 50 }}>
|
||||||
|
{highlightEndTime ? formatTime(highlightEndTime) : '—'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setHighlightEndTime(Math.round(currentTime))}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text2)' }}
|
||||||
|
>Set End</button>
|
||||||
|
{highlightEndTime && (
|
||||||
|
<button
|
||||||
|
onClick={() => setHighlightEndTime(null)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 6px', background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text3)' }}
|
||||||
|
>Clear</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{highlightEndTime && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--teal)', marginLeft: 8 }}>
|
||||||
|
Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag and note row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{TAGS.map(t => (
|
||||||
|
<button key={t} onClick={() => setNewHighlightTag(t)} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 8px', borderRadius: 3,
|
||||||
|
background: newHighlightTag === t ? TAG_COLORS[t] : 'var(--bg4)',
|
||||||
|
color: newHighlightTag === t ? 'var(--bg)' : 'var(--text2)',
|
||||||
|
border: `1px solid ${TAG_COLORS[t] || 'var(--border)'}`,
|
||||||
|
}}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={newHighlightNote}
|
||||||
|
onChange={e => setNewHighlightNote(e.target.value)}
|
||||||
|
placeholder="Note (optional)…"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addHighlight()}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: 'var(--bg4)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4, padding: '4px 10px', color: 'var(--text)',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button onClick={addHighlight} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11, padding: '4px 12px',
|
||||||
|
background: 'var(--amber)', color: 'var(--bg)', borderRadius: 4, fontWeight: 600,
|
||||||
|
}}>Save</button>
|
||||||
|
<button onClick={() => { setShowAddHighlight(false); setHighlightStartTime(null); setHighlightEndTime(null); }} style={{ color: 'var(--text3)', fontSize: 16 }}>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main: player + transcript + highlights */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left: Player + Transcript */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Player */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
|
||||||
|
{/* Waveform */}
|
||||||
|
<div
|
||||||
|
onClick={seek}
|
||||||
|
style={{ height: 56, display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', marginBottom: 8, position: 'relative' }}
|
||||||
|
>
|
||||||
|
{waveHeights.current.map((h, i) => {
|
||||||
|
const barPct = i / waveformBars;
|
||||||
|
const barTime = barPct * totalSeconds;
|
||||||
|
const isPast = barPct <= progressPct / 100;
|
||||||
|
const isActive = Math.abs(barPct - progressPct / 100) < 0.015;
|
||||||
|
|
||||||
|
// Check if bar is within any highlight range (or near a point highlight)
|
||||||
|
const inHighlight = recHighlights.some(hl => {
|
||||||
|
if (hl.endSeconds != null) {
|
||||||
|
// Range highlight - check if bar is within range
|
||||||
|
return barTime >= hl.startSeconds && barTime <= hl.endSeconds;
|
||||||
|
} else {
|
||||||
|
// Point highlight - check if near
|
||||||
|
return Math.abs(hl.startSeconds - barTime) < (totalSeconds / waveformBars);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if bar is within the pending highlight being created
|
||||||
|
const inPendingHighlight = showAddHighlight && highlightStartTime != null && (
|
||||||
|
highlightEndTime != null
|
||||||
|
? barTime >= highlightStartTime && barTime <= highlightEndTime
|
||||||
|
: Math.abs(highlightStartTime - barTime) < (totalSeconds / waveformBars)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
flex: 1,
|
||||||
|
height: `${Math.round(h * 100)}%`,
|
||||||
|
background: inPendingHighlight ? 'var(--red)' : inHighlight ? 'var(--amber)' : isPast ? 'var(--teal)' : 'var(--bg4)',
|
||||||
|
borderRadius: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
transition: 'background 0.05s',
|
||||||
|
opacity: isActive ? 1 : 0.85,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Highlight range markers when creating */}
|
||||||
|
{showAddHighlight && highlightStartTime != null && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: `${(highlightStartTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
|
||||||
|
width: 2, background: 'var(--red)', pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{showAddHighlight && highlightEndTime != null && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: `${(highlightEndTime / totalSeconds) * 100}%`, top: 0, bottom: 0,
|
||||||
|
width: 2, background: 'var(--red)', pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playhead */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: `${progressPct}%`, top: 0, bottom: 0,
|
||||||
|
width: 2, background: 'var(--amber)', pointerEvents: 'none',
|
||||||
|
boxShadow: '0 0 6px var(--amber)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden audio element */}
|
||||||
|
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<button onClick={() => skipTime(-30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-30s</button>
|
||||||
|
<button onClick={() => skipTime(-5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>-5s</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
background: 'var(--amber)', color: 'var(--bg)',
|
||||||
|
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>{playing ? '⏸' : '▶'}</button>
|
||||||
|
<button onClick={() => skipTime(5)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+5s</button>
|
||||||
|
<button onClick={() => skipTime(30)} style={{ color: 'var(--text2)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>+30s</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Speed */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{[0.5, 1.0, 1.5, 2.0, 3.0].map(s => (
|
||||||
|
<button key={s} onClick={() => setSpeed(s)} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 7px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: speed === s ? 'var(--amber)' : 'var(--bg4)',
|
||||||
|
color: speed === s ? 'var(--bg)' : 'var(--text3)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>{s}×</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', minWidth: 100, textAlign: 'right' }}>
|
||||||
|
{formatTime(currentTime)} / {formatTime(totalSeconds)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Transcript header with scroll-to-current button */}
|
||||||
|
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
Transcript · {r.transcript?.length || 0} segments
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{r.hasTranscript && r.transcript.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Find the segment closest to current time
|
||||||
|
const el = transcriptRef.current?.querySelector(`[data-seg-id="${activeSegId}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else {
|
||||||
|
// Find closest segment
|
||||||
|
const closest = r.transcript.reduce((prev, curr) =>
|
||||||
|
Math.abs(curr.startSeconds - currentTime) < Math.abs(prev.startSeconds - currentTime) ? curr : prev
|
||||||
|
);
|
||||||
|
const closestEl = transcriptRef.current?.querySelector(`[data-seg-id="${closest.id}"]`);
|
||||||
|
if (closestEl) closestEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px',
|
||||||
|
background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4,
|
||||||
|
color: 'var(--amber)', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓ Go to {formatTime(currentTime)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div ref={transcriptRef} style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||||
|
{!r.hasTranscript || r.transcript.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '20px 0' }}>
|
||||||
|
No transcript available. Run speech-to-text batch job to generate.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{r.transcript.map(seg => {
|
||||||
|
const isActive = seg.id === activeSegId;
|
||||||
|
const hasHighlight = recHighlights.some(h => h.segmentId === seg.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seg.id}
|
||||||
|
data-seg-id={seg.id}
|
||||||
|
onClick={() => seekToTime(seg.startSeconds)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', gap: 12, padding: '10px 12px', borderRadius: 5,
|
||||||
|
background: isActive ? 'var(--amber-glow)' : 'transparent',
|
||||||
|
border: `1px solid ${isActive ? 'var(--amber-dim)' : hasHighlight ? 'var(--amber-dim)' : 'transparent'}`,
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg3)'; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{ flexShrink: 0, paddingTop: 2 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: isActive ? 'var(--amber)' : 'var(--text3)' }}>
|
||||||
|
{formatTime(seg.startSeconds)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>
|
||||||
|
{Math.round(seg.confidence * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, fontFamily: 'var(--font-sans)', fontSize: 14, lineHeight: 1.6, color: isActive ? 'var(--text)' : 'var(--text2)', textWrap: 'pretty' }}>
|
||||||
|
{seg.text}
|
||||||
|
</div>
|
||||||
|
{hasHighlight && (
|
||||||
|
<div style={{ flexShrink: 0, color: 'var(--amber)', fontSize: 12, paddingTop: 2 }}>◈</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Highlights panel */}
|
||||||
|
<div style={{ width: 260, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg2)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
Highlights · {recHighlights.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px' }}>
|
||||||
|
{recHighlights.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '8px 4px' }}>
|
||||||
|
No highlights yet.<br />Press ◈ Highlight to mark a moment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{recHighlights
|
||||||
|
.sort((a, b) => a.startSeconds - b.startSeconds)
|
||||||
|
.map(h => (
|
||||||
|
<HighlightCard key={h.id} highlight={h} tagColors={TAG_COLORS} onSeek={seekToTime} onRemove={removeHighlight} formatTime={formatTime} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_COLORS = {
|
||||||
|
important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
|
||||||
|
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function HighlightCard({ highlight: h, tagColors, onSeek, onRemove, formatTime }) {
|
||||||
|
const color = tagColors[h.tag] || 'var(--text3)';
|
||||||
|
const hasRange = h.endSeconds != null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg3)', border: `1px solid ${color}44`,
|
||||||
|
borderLeft: `3px solid ${color}`, borderRadius: '0 4px 4px 0',
|
||||||
|
padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(h.startSeconds)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}
|
||||||
|
>
|
||||||
|
{formatTime(h.startSeconds)}
|
||||||
|
{hasRange && ` → ${formatTime(h.endSeconds)}`}
|
||||||
|
</button>
|
||||||
|
{hasRange && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--teal)' }}>
|
||||||
|
({formatTime(h.endSeconds - h.startSeconds)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, padding: '1px 5px', borderRadius: 3, background: `${color}22`, color }}>{h.tag}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => onRemove(h.id)} style={{ color: 'var(--text3)', fontSize: 13, lineHeight: 1 }}>×</button>
|
||||||
|
</div>
|
||||||
|
{h.note && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text2)', lineHeight: 1.5 }}>{h.note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS });
|
||||||
212
clio-ui/components/SearchView.jsx
Normal file
212
clio-ui/components/SearchView.jsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// SearchView: full-text transcript search + filters
|
||||||
|
const { useState: useStateSV, useMemo: useMemoSV } = React;
|
||||||
|
|
||||||
|
function SearchView({ data, setSelectedRecording, setView }) {
|
||||||
|
const [query, setQuery] = useStateSV('');
|
||||||
|
const [filterSource, setFilterSource] = useStateSV([]);
|
||||||
|
const [filterQuality, setFilterQuality] = useStateSV([]);
|
||||||
|
const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false);
|
||||||
|
const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false);
|
||||||
|
|
||||||
|
const results = useMemoSV(() => {
|
||||||
|
if (!query.trim() && filterSource.length === 0 && filterQuality.length === 0 && !filterHasTranscript && !filterHasHighlights) return [];
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
const matches = [];
|
||||||
|
|
||||||
|
for (const r of data.recordings) {
|
||||||
|
if (filterSource.length > 0 && !filterSource.includes(r.source)) continue;
|
||||||
|
if (filterQuality.length > 0 && !filterQuality.includes(r.quality)) continue;
|
||||||
|
if (filterHasTranscript && !r.hasTranscript) continue;
|
||||||
|
if (filterHasHighlights && r.highlights.length === 0) continue;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
matches.push({ recording: r, matchingSegments: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingSegments = r.transcript.filter(s => s.text.toLowerCase().includes(q));
|
||||||
|
if (matchingSegments.length > 0) {
|
||||||
|
matches.push({ recording: r, matchingSegments });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.sort((a, b) => b.matchingSegments.length - a.matchingSegments.length || b.recording.startTime - a.recording.startTime);
|
||||||
|
}, [query, filterSource, filterQuality, filterHasTranscript, filterHasHighlights, data]);
|
||||||
|
|
||||||
|
function toggleArr(arr, setArr, val) {
|
||||||
|
setArr(prev => prev.includes(val) ? prev.filter(x => x !== val) : [...prev, val]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(secs) {
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightText(text, query) {
|
||||||
|
if (!query.trim()) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<mark style={{ background: 'var(--amber)', color: 'var(--bg)', borderRadius: 2, padding: '0 1px' }}>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</mark>
|
||||||
|
{text.slice(idx + query.length)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES = ['portable', 'stationary', 'phone'];
|
||||||
|
const QUALITIES = ['excellent', 'good', 'fair', 'poor'];
|
||||||
|
|
||||||
|
const hasFilters = query.trim() || filterSource.length > 0 || filterQuality.length > 0 || filterHasTranscript || filterHasHighlights;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Search bar */}
|
||||||
|
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
|
||||||
|
<div style={{ position: 'relative', marginBottom: 12 }}>
|
||||||
|
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text3)', fontSize: 16, pointerEvents: 'none' }}>⌕</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="Search transcripts…"
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%', background: 'var(--bg3)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6, padding: '10px 14px 10px 36px',
|
||||||
|
color: 'var(--text)', fontFamily: 'var(--font-sans)', fontSize: 15,
|
||||||
|
outline: 'none', transition: 'border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--amber)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border)'}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button onClick={() => setQuery('')} style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text3)', fontSize: 18 }}>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Filter:</span>
|
||||||
|
{SOURCES.map(s => (
|
||||||
|
<FilterChip key={s} label={s} active={filterSource.includes(s)} colorClass={`source-${s}`} onClick={() => toggleArr(filterSource, setFilterSource, s)} />
|
||||||
|
))}
|
||||||
|
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
|
||||||
|
{QUALITIES.map(q => (
|
||||||
|
<FilterChip key={q} label={q} active={filterQuality.includes(q)} colorClass={`q-${q}`} onClick={() => toggleArr(filterQuality, setFilterQuality, q)} />
|
||||||
|
))}
|
||||||
|
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
|
||||||
|
<FilterChip label="has transcript" active={filterHasTranscript} colorClass="" onClick={() => setFilterHasTranscript(v => !v)} />
|
||||||
|
<FilterChip label="has highlights" active={filterHasHighlights} colorClass="" onClick={() => setFilterHasHighlights(v => !v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||||
|
{!hasFilters ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60%', gap: 8 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, color: 'var(--bg4)' }}>⌕</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text3)' }}>Search transcripts or apply filters</div>
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: '24px 0', textAlign: 'center' }}>No results found.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', marginBottom: 12 }}>
|
||||||
|
{results.length} recording{results.length !== 1 ? 's' : ''} · {results.reduce((a, r) => a + r.matchingSegments.length, 0)} segment matches
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{results.map(({ recording, matchingSegments }) => (
|
||||||
|
<SearchResultCard
|
||||||
|
key={recording.id}
|
||||||
|
recording={recording}
|
||||||
|
matchingSegments={matchingSegments}
|
||||||
|
query={query}
|
||||||
|
highlightText={highlightText}
|
||||||
|
formatTime={formatTime}
|
||||||
|
onOpen={(seekTime) => { setSelectedRecording(recording, seekTime); setView('detail'); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ label, active, colorClass, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={active ? colorClass : ''}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 9px', borderRadius: 3,
|
||||||
|
background: active ? 'var(--bg4)' : 'var(--bg3)',
|
||||||
|
border: `1px solid ${active ? 'currentColor' : 'var(--border)'}`,
|
||||||
|
color: active ? undefined : 'var(--text3)',
|
||||||
|
textTransform: 'lowercase', cursor: 'pointer', transition: 'all 0.1s',
|
||||||
|
}}
|
||||||
|
>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultCard({ recording: r, matchingSegments, query, highlightText, formatTime, onOpen }) {
|
||||||
|
const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
|
||||||
|
const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// First matching segment time, or null to start at beginning
|
||||||
|
const firstMatchTime = matchingSegments.length > 0 ? matchingSegments[0].startSeconds : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
{/* Card header - opens at first match */}
|
||||||
|
<div
|
||||||
|
onClick={() => onOpen(firstMatchTime)}
|
||||||
|
style={{ padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', borderBottom: matchingSegments.length > 0 ? '1px solid var(--border)' : 'none' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{dateStr} {timeStr}</span>
|
||||||
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{dur}</span>
|
||||||
|
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{r.quality}</span>
|
||||||
|
{r.highlights.length > 0 && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{matchingSegments.length > 0 && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--teal)' }}>{matchingSegments.length} match{matchingSegments.length !== 1 ? 'es' : ''}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text3)', fontSize: 12 }}>→</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matching segments - each opens at its specific time */}
|
||||||
|
{matchingSegments.slice(0, 3).map(seg => (
|
||||||
|
<div key={seg.id} onClick={() => onOpen(seg.startSeconds)} style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', display: 'flex', gap: 10 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', flexShrink: 0, paddingTop: 2 }}>{formatTime(seg.startSeconds)}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--text2)', lineHeight: 1.5 }}>
|
||||||
|
{highlightText(seg.text, query)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{matchingSegments.length > 3 && (
|
||||||
|
<div onClick={() => onOpen(firstMatchTime)} style={{ padding: '6px 14px', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--amber)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text3)'}
|
||||||
|
>+{matchingSegments.length - 3} more →</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { SearchView, FilterChip, SearchResultCard });
|
||||||
99
clio-ui/components/Sidebar.jsx
Normal file
99
clio-ui/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Sidebar navigation component
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: '▦' },
|
||||||
|
{ id: 'calendar', label: 'Calendar', icon: '◫' },
|
||||||
|
{ id: 'search', label: 'Search', icon: '⌕' },
|
||||||
|
{ id: 'highlights', label: 'Highlights', icon: '◈' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Sidebar({ view, setView, data }) {
|
||||||
|
const totalGB = (data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024).toFixed(1);
|
||||||
|
const totalCount = data.recordings.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 'var(--sidebar-w)', flexShrink: 0,
|
||||||
|
background: 'var(--bg2)', borderRight: '1px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
}}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ padding: '18px 16px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 4 }}>RecordingsViewer</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>
|
||||||
|
<span><span style={{ color: 'var(--amber)' }}>{totalCount}</span> files</span>
|
||||||
|
<span><span style={{ color: 'var(--amber)' }}>{totalGB}</span> GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav style={{ flex: 1, padding: '10px 0' }}>
|
||||||
|
{NAV_ITEMS.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setView(item.id)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '9px 16px', textAlign: 'left',
|
||||||
|
background: view === item.id ? 'var(--amber-glow)' : 'none',
|
||||||
|
color: view === item.id ? 'var(--amber)' : 'var(--text2)',
|
||||||
|
borderLeft: view === item.id ? '2px solid var(--amber)' : '2px solid transparent',
|
||||||
|
fontSize: 13, fontWeight: view === item.id ? 500 : 400,
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 15, fontFamily: 'var(--font-mono)', opacity: 0.8 }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Device status */}
|
||||||
|
<DeviceStatus devices={data.devices} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceStatus({ devices }) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
function hoursSince(date) {
|
||||||
|
return (now - date) / (1000 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSince(date) {
|
||||||
|
const h = hoursSince(date);
|
||||||
|
if (h < 1) return `${Math.round(h * 60)}m ago`;
|
||||||
|
if (h < 24) return `${Math.round(h)}h ago`;
|
||||||
|
return `${Math.round(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Devices</div>
|
||||||
|
{Object.entries(devices).map(([key, dev]) => {
|
||||||
|
const hours = hoursSince(dev.lastSeen);
|
||||||
|
const bad = hours > 24;
|
||||||
|
const color = bad ? 'var(--red)' : 'var(--green)';
|
||||||
|
const dotColor = bad ? 'var(--red)' : 'var(--green)';
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: '50%', background: dotColor,
|
||||||
|
boxShadow: bad ? 'none' : `0 0 4px ${dotColor}`,
|
||||||
|
flexShrink: 0, display: 'inline-block',
|
||||||
|
}}></span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', textTransform: 'capitalize' }}>{key}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color }}>{fmtSince(dev.lastSeen)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', paddingLeft: 12 }}>{dev.model}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Sidebar, DeviceStatus });
|
||||||
52
clio-ui/components/Tweaks.jsx
Normal file
52
clio-ui/components/Tweaks.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Tweaks panel for RecordingsViewer
|
||||||
|
// Loaded after tweaks-panel.jsx
|
||||||
|
|
||||||
|
function TweaksApp() {
|
||||||
|
const { TweaksPanel, TweakSection, TweakToggle, TweakRadio, TweakColor, useTweaks } = window;
|
||||||
|
|
||||||
|
const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
|
||||||
|
"accentColor": "oklch(0.75 0.15 65)",
|
||||||
|
"density": "normal",
|
||||||
|
"monoTimestamps": true,
|
||||||
|
"showQualityColors": true,
|
||||||
|
"sidebarCollapsed": false
|
||||||
|
}/*EDITMODE-END*/);
|
||||||
|
|
||||||
|
// Apply tweaks to CSS vars live
|
||||||
|
React.useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
// Accent color
|
||||||
|
root.style.setProperty('--amber', tweaks.accentColor);
|
||||||
|
// Density
|
||||||
|
const pad = tweaks.density === 'compact' ? '6px 10px' : tweaks.density === 'comfortable' ? '14px 18px' : '9px 12px';
|
||||||
|
root.style.setProperty('--row-padding', pad);
|
||||||
|
// Monospace timestamps
|
||||||
|
document.querySelectorAll('.mono-ts').forEach(el => {
|
||||||
|
el.style.fontFamily = tweaks.monoTimestamps ? 'var(--font-mono)' : 'var(--font-sans)';
|
||||||
|
});
|
||||||
|
}, [tweaks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweaksPanel>
|
||||||
|
<TweakSection label="Appearance">
|
||||||
|
<TweakColor label="Accent color" value={tweaks.accentColor} onChange={v => setTweak('accentColor', v)} />
|
||||||
|
<TweakRadio
|
||||||
|
label="Density"
|
||||||
|
value={tweaks.density}
|
||||||
|
options={[{ label: 'Compact', value: 'compact' }, { label: 'Normal', value: 'normal' }, { label: 'Airy', value: 'comfortable' }]}
|
||||||
|
onChange={v => setTweak('density', v)}
|
||||||
|
/>
|
||||||
|
</TweakSection>
|
||||||
|
<TweakSection label="Display">
|
||||||
|
<TweakToggle label="Monospace timestamps" value={tweaks.monoTimestamps} onChange={v => setTweak('monoTimestamps', v)} />
|
||||||
|
<TweakToggle label="Quality color coding" value={tweaks.showQualityColors} onChange={v => setTweak('showQualityColors', v)} />
|
||||||
|
</TweakSection>
|
||||||
|
</TweaksPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount tweaks panel in a separate div
|
||||||
|
const tweaksRoot = document.createElement('div');
|
||||||
|
tweaksRoot.id = 'tweaks-root';
|
||||||
|
document.body.appendChild(tweaksRoot);
|
||||||
|
ReactDOM.createRoot(tweaksRoot).render(<TweaksApp />);
|
||||||
425
clio-ui/tweaks-panel.jsx
Normal file
425
clio-ui/tweaks-panel.jsx
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-noncommentable=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakColor({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue