icon
All checks were successful
Deploy UI / deploy (push) Successful in 1s

This commit is contained in:
Your Name 2026-05-19 20:54:48 -04:00
parent 1ca5e4a3a8
commit aa5741eafb
8 changed files with 76 additions and 59 deletions

67
TODO.md
View file

@ -1,26 +1,61 @@
# Clio TODO # Clio TODO
## Audio Player Issues ## Requirements / Expected Behaviors
- [ ] Short audio files show wrong duration (e.g., shows 1 second instead of 13 seconds)
- Progress bar doesn't move for these
- Can't seek
- Need to investigate why audioDuration isn't being set correctly for short files
- [ ] Hard to click on audio controls for small recordings These are expected behaviors that should be preserved. If something breaks, check this list.
- Add proper prev/next buttons for navigation
- Make clickable area larger
## URL Routing Issue ### Audio Player
- [ ] URL shows `#/detail` instead of `#/recording/{id}` - [ ] Audio should auto-play when opening a recording
- Loses recording info on refresh - [x] Clicking a highlight should seek to that position and auto-play
- Need to debug why buildHash isn't being called or URL isn't updating - [x] Clicking a search result should seek to that position
- [ ] Skip buttons (-30s, -5s, +5s, +30s) should work
- [ ] Speed controls should persist during playback
- [ ] Native audio controls (play/pause, seek, volume) should work
## Security (Completed) ### Navigation
- [x] Add API key authentication to all endpoints - [x] URL should show `#/recording/{id}` when viewing a recording
- [x] URL should show `#/calendar/{date}` when viewing a specific day
- [x] Browser back/forward should work correctly
- [x] Refreshing page should preserve current view
### Dates/Times
- [x] All dates should display in local timezone (not UTC)
- [ ] Calendar should highlight today's date
### Highlights
- [ ] Creating a highlight should save to API
- [ ] Deleting a highlight should remove from API
- [ ] Highlights should appear in the highlights panel
- [ ] Clicking a highlight in HighlightsView should open recording at that time
## Known Issues
### Audio Player
- [ ] Short audio files may show wrong duration initially
- [ ] DayView density bars may show duplicate lines
### Data Import
- [ ] Need to import large historical dataset before removing duplicate calendar
## Completed
### Security
- [x] API key authentication on all endpoints
- [x] UI prompts for API key and stores in localStorage - [x] UI prompts for API key and stores in localStorage
- [x] Audio fetched via authenticated blob URL - [x] Audio fetched via authenticated blob URL
## Deployment ### Deployment
- [x] Forgejo workflow for UI deployment - [x] Forgejo workflow for UI deployment
- [x] Forgejo workflow for API deployment (needs sudo permission for restart) - [x] Forgejo workflow for API deployment
- [ ] Add sudoers rule for gitea-runner to restart phone-recorder - [ ] Add sudoers rule for gitea-runner to restart phone-recorder
### Cleanup
- [x] Removed dead quality code (was hardcoded to 'good')
- [x] Simplified audio player to native HTML5 controls
- [x] Added favicon
## Future Ideas
- [ ] Prev/next recording buttons
- [ ] Keyboard shortcuts (space to play/pause, arrow keys to seek)
- [ ] Playwright tests
- [ ] Waveform visualization (when transcripts available, show speaking density)

View file

@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecordingsViewer</title> <title>Clio</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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" />
@ -50,12 +51,6 @@
.mono { font-family: var(--font-mono); } .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 */
.source-pill { .source-pill {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;

View file

@ -78,7 +78,6 @@ function transformRecording(rec) {
return { return {
id: rec.id, id: rec.id,
source: rec.source_name, source: rec.source_name,
quality: 'good', // API doesn't track quality, default to good
durationMinutes, durationMinutes,
startTime, startTime,
endTime, endTime,
@ -103,7 +102,6 @@ function transformRecording(rec) {
})), })),
sampleRate: rec.sample_rate || 44100, sampleRate: rec.sample_rate || 44100,
bitDepth: rec.bit_depth || 16, bitDepth: rec.bit_depth || 16,
peakDB: -30,
audioPath: rec.audio_path, audioPath: rec.audio_path,
}; };
} }

View file

@ -46,9 +46,6 @@ function Dashboard({ data, setView, setSelectedDay, setSelectedRecording }) {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
} }
function qualityClass(q) {
return `q-${q}`;
}
return ( return (
<div style={{ flex: 1, overflow: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ flex: 1, overflow: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column', gap: 24 }}>
@ -196,7 +193,6 @@ function RecordingRow({ recording, onClick }) {
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', width: 80, flexShrink: 0 }}>{dateStr} {timeStr}</div> <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> <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, 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> <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', flexShrink: 0 }}>{r.fileSizeMB} MB</div>
{r.highlights.length > 0 && ( {r.highlights.length > 0 && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}> <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>

View file

@ -184,10 +184,6 @@ function DayTimeline({ recordings, onSelect }) {
return Math.max((r.durationMinutes / 60) * HOUR_H, 8); 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; const totalH = 24 * HOUR_H;
@ -230,7 +226,7 @@ function DayTimeline({ recordings, onSelect }) {
<div <div
key={r.id} key={r.id}
onClick={() => onSelect(r)} onClick={() => onSelect(r)}
title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`} title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`}
style={{ style={{
position: 'absolute', position: 'absolute',
top: top + 1, top: top + 1,
@ -254,7 +250,6 @@ function DayTimeline({ recordings, onSelect }) {
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}> <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 {Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
</span> </span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }} className={`q-${r.quality}`}>{r.quality}</span>
{r.highlights.length > 0 && ( {r.highlights.length > 0 && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span> <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>
)} )}
@ -321,7 +316,6 @@ function DayTimeline({ recordings, onSelect }) {
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m</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>
<div style={{ display: 'flex', gap: 10 }}> <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> <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.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>} {r.highlights.length > 0 && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>}

View file

@ -88,6 +88,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
// Handle seeking to initial time (from search results, highlights, etc.) // Handle seeking to initial time (from search results, highlights, etc.)
useEffectRD(() => { useEffectRD(() => {
if (initialSeekTime == null) return; if (initialSeekTime == null) return;
if (!audioBlobUrl) return; // Wait for audio to be loaded
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@ -103,7 +104,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
audio.addEventListener('loadedmetadata', doSeek, { once: true }); audio.addEventListener('loadedmetadata', doSeek, { once: true });
return () => audio.removeEventListener('loadedmetadata', doSeek); return () => audio.removeEventListener('loadedmetadata', doSeek);
} }
}, [initialSeekTime, onSeekHandled]); }, [initialSeekTime, onSeekHandled, audioBlobUrl]);
// Sync playback state with audio element // Sync playback state with audio element
useEffectRD(() => { useEffectRD(() => {
@ -279,13 +280,10 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)', 'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
}; };
const qualityDetails = [ const recordingDetails = [
{ label: 'Quality', value: r.quality, cls: `q-${r.quality}` }, { label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz` },
{ label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz`, cls: '' }, { label: 'Bit Depth', value: `${r.bitDepth}-bit` },
{ label: 'Bit Depth', value: `${r.bitDepth}-bit`, cls: '' }, { label: 'Size', value: `${r.fileSizeMB} MB` },
{ 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 ( return (
@ -300,10 +298,10 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
</div> </div>
<div style={{ display: 'flex', gap: 10, marginTop: 3, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 10, marginTop: 3, alignItems: 'center' }}>
<span className={`source-pill source-${r.source}`}>{r.source}</span> <span className={`source-pill source-${r.source}`}>{r.source}</span>
{qualityDetails.map(q => ( {recordingDetails.map(d => (
<span key={q.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}> <span key={d.label} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>
<span style={{ color: 'var(--text3)' }}>{q.label}: </span> <span style={{ color: 'var(--text3)' }}>{d.label}: </span>
<span className={q.cls} style={{ color: q.cls ? undefined : 'var(--text2)' }}>{q.value}</span> <span style={{ color: 'var(--text2)' }}>{d.value}</span>
</span> </span>
))} ))}
</div> </div>

View file

@ -4,18 +4,16 @@ const { useState: useStateSV, useMemo: useMemoSV } = React;
function SearchView({ data, setSelectedRecording, setView }) { function SearchView({ data, setSelectedRecording, setView }) {
const [query, setQuery] = useStateSV(''); const [query, setQuery] = useStateSV('');
const [filterSource, setFilterSource] = useStateSV([]); const [filterSource, setFilterSource] = useStateSV([]);
const [filterQuality, setFilterQuality] = useStateSV([]);
const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false); const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false);
const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false); const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false);
const results = useMemoSV(() => { const results = useMemoSV(() => {
if (!query.trim() && filterSource.length === 0 && filterQuality.length === 0 && !filterHasTranscript && !filterHasHighlights) return []; if (!query.trim() && filterSource.length === 0 && !filterHasTranscript && !filterHasHighlights) return [];
const q = query.toLowerCase().trim(); const q = query.toLowerCase().trim();
const matches = []; const matches = [];
for (const r of data.recordings) { for (const r of data.recordings) {
if (filterSource.length > 0 && !filterSource.includes(r.source)) continue; if (filterSource.length > 0 && !filterSource.includes(r.source)) continue;
if (filterQuality.length > 0 && !filterQuality.includes(r.quality)) continue;
if (filterHasTranscript && !r.hasTranscript) continue; if (filterHasTranscript && !r.hasTranscript) continue;
if (filterHasHighlights && r.highlights.length === 0) continue; if (filterHasHighlights && r.highlights.length === 0) continue;
@ -31,7 +29,7 @@ function SearchView({ data, setSelectedRecording, setView }) {
} }
return matches.sort((a, b) => b.matchingSegments.length - a.matchingSegments.length || b.recording.startTime - a.recording.startTime); return matches.sort((a, b) => b.matchingSegments.length - a.matchingSegments.length || b.recording.startTime - a.recording.startTime);
}, [query, filterSource, filterQuality, filterHasTranscript, filterHasHighlights, data]); }, [query, filterSource, filterHasTranscript, filterHasHighlights, data]);
function toggleArr(arr, setArr, val) { function toggleArr(arr, setArr, val) {
setArr(prev => prev.includes(val) ? prev.filter(x => x !== val) : [...prev, val]); setArr(prev => prev.includes(val) ? prev.filter(x => x !== val) : [...prev, val]);
@ -61,9 +59,8 @@ function SearchView({ data, setSelectedRecording, setView }) {
} }
const SOURCES = ['portable', 'stationary', 'phone']; const SOURCES = ['portable', 'stationary', 'phone'];
const QUALITIES = ['excellent', 'good', 'fair', 'poor'];
const hasFilters = query.trim() || filterSource.length > 0 || filterQuality.length > 0 || filterHasTranscript || filterHasHighlights; const hasFilters = query.trim() || filterSource.length > 0 || filterHasTranscript || filterHasHighlights;
return ( return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
@ -97,10 +94,6 @@ function SearchView({ data, setSelectedRecording, setView }) {
<FilterChip key={s} label={s} active={filterSource.includes(s)} colorClass={`source-${s}`} onClick={() => toggleArr(filterSource, setFilterSource, 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)' }} /> <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 transcript" active={filterHasTranscript} colorClass="" onClick={() => setFilterHasTranscript(v => !v)} />
<FilterChip label="has highlights" active={filterHasHighlights} colorClass="" onClick={() => setFilterHasHighlights(v => !v)} /> <FilterChip label="has highlights" active={filterHasHighlights} colorClass="" onClick={() => setFilterHasHighlights(v => !v)} />
</div> </div>
@ -176,7 +169,6 @@ function SearchResultCard({ recording: r, matchingSegments, query, highlightText
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{dateStr} {timeStr}</span> <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 className={`source-pill source-${r.source}`}>{r.source}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{dur}</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 && ( {r.highlights.length > 0 && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span> <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>
)} )}

9
clio-ui/favicon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1815"/>
<g fill="#d4a054">
<rect x="6" y="12" width="3" height="8" rx="1"/>
<rect x="11" y="8" width="3" height="16" rx="1"/>
<rect x="16" y="10" width="3" height="12" rx="1"/>
<rect x="21" y="6" width="3" height="20" rx="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 367 B