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