adding the ui

This commit is contained in:
Your Name 2026-05-19 17:11:45 -04:00
parent 14445e7242
commit eaeac63ef3
12 changed files with 2638 additions and 0 deletions

66
.gitignore vendored Normal file
View 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

View 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
View file

367
clio-ui/components/App.jsx Normal file
View 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 />);

View 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 });

View 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 });

View 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 });

View 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 });

View 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 });

View 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 });

View 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
View 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,
});