adding the ui
This commit is contained in:
parent
14445e7242
commit
eaeac63ef3
12 changed files with 2638 additions and 0 deletions
337
clio-ui/components/DayView.jsx
Normal file
337
clio-ui/components/DayView.jsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// DayView: calendar month grid + day timeline with audio density visualization
|
||||
const { useState: useStateDV, useEffect: useEffectDV, useRef: useRefDV } = React;
|
||||
|
||||
function DayView({ data, selectedDay, setSelectedDay, setSelectedRecording, setView }) {
|
||||
const now = new Date();
|
||||
const [viewMonth, setViewMonth] = useStateDV(() => {
|
||||
if (selectedDay) {
|
||||
const d = new Date(selectedDay);
|
||||
return { year: d.getFullYear(), month: d.getMonth() };
|
||||
}
|
||||
return { year: now.getFullYear(), month: now.getMonth() };
|
||||
});
|
||||
|
||||
// Build day map
|
||||
const dayMap = {};
|
||||
for (const r of data.recordings) {
|
||||
const key = r.startTime.toISOString().slice(0, 10);
|
||||
if (!dayMap[key]) dayMap[key] = [];
|
||||
dayMap[key].push(r);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...Object.values(dayMap).map(d => d.length), 1);
|
||||
|
||||
// Calendar grid
|
||||
const { year, month } = viewMonth;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDow = firstDay.getDay();
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
const prevMonth = () => {
|
||||
setViewMonth(m => {
|
||||
const d = new Date(m.year, m.month - 1, 1);
|
||||
return { year: d.getFullYear(), month: d.getMonth() };
|
||||
});
|
||||
};
|
||||
const nextMonth = () => {
|
||||
setViewMonth(m => {
|
||||
const d = new Date(m.year, m.month + 1, 1);
|
||||
return { year: d.getFullYear(), month: d.getMonth() };
|
||||
});
|
||||
};
|
||||
|
||||
const monthLabel = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||
|
||||
const dayRecs = selectedDay ? (dayMap[selectedDay] || []).sort((a, b) => a.startTime - b.startTime) : [];
|
||||
|
||||
function fmtDay(dateStr) {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
|
||||
{/* Left: Month calendar */}
|
||||
<div style={{
|
||||
width: 300, flexShrink: 0, borderRight: '1px solid var(--border)',
|
||||
background: 'var(--bg2)', display: 'flex', flexDirection: 'column',
|
||||
padding: '20px 16px', gap: 16, overflow: 'auto',
|
||||
}}>
|
||||
{/* Month nav */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<button onClick={prevMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}>‹</button>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)', fontWeight: 500 }}>{monthLabel}</div>
|
||||
<button onClick={nextMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}>›</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{['S','M','T','W','T','F','S'].map((d, i) => (
|
||||
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'center', paddingBottom: 4 }}>{d}</div>
|
||||
))}
|
||||
{/* Empty cells */}
|
||||
{Array.from({ length: startDow }).map((_, i) => <div key={`e${i}`} />)}
|
||||
{/* Day cells */}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const d = i + 1;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const recs = dayMap[dateStr] || [];
|
||||
const isSelected = selectedDay === dateStr;
|
||||
const isToday = dateStr === now.toISOString().slice(0, 10);
|
||||
const hasRecs = recs.length > 0;
|
||||
const intensity = hasRecs ? recs.length / maxCount : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={d}
|
||||
onClick={() => hasRecs && setSelectedDay(dateStr)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
aspectRatio: '1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
cursor: hasRecs ? 'pointer' : 'default',
|
||||
background: isSelected ? 'var(--amber)' : isToday ? 'var(--bg4)' : 'transparent',
|
||||
outline: isToday && !isSelected ? '1px solid var(--border)' : 'none',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (hasRecs && !isSelected) e.currentTarget.style.background = 'var(--bg4)'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isToday ? 'var(--bg4)' : 'transparent'; }}
|
||||
>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: isSelected ? 'var(--bg)' : isToday ? 'var(--amber)' : hasRecs ? 'var(--text)' : 'var(--text3)',
|
||||
fontWeight: isSelected || isToday ? 600 : 400,
|
||||
lineHeight: 1,
|
||||
}}>{d}</span>
|
||||
{hasRecs && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 3,
|
||||
display: 'flex', gap: 1, justifyContent: 'center',
|
||||
}}>
|
||||
{recs.slice(0, 3).map((_, ri) => (
|
||||
<div key={ri} style={{
|
||||
width: 3, height: 3, borderRadius: '50%',
|
||||
background: isSelected ? 'var(--bg)' : 'var(--amber)',
|
||||
opacity: 0.8,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Day summary */}
|
||||
{selectedDay && dayRecs.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
{dayRecs.length} recordings · {Math.round(dayRecs.reduce((a, r) => a + r.durationMinutes, 0) / 60)}h total
|
||||
</div>
|
||||
{['portable','stationary','phone'].map(src => {
|
||||
const count = dayRecs.filter(r => r.source === src).length;
|
||||
if (!count) return null;
|
||||
return (
|
||||
<div key={src} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span className={`source-pill source-${src}`}>{src}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Day timeline */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||
{!selectedDay ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
||||
Select a day to view recordings
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', marginBottom: 6 }}>
|
||||
{fmtDay(selectedDay)}
|
||||
</div>
|
||||
{dayRecs.length === 0 ? (
|
||||
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, marginTop: 20 }}>No recordings this day.</div>
|
||||
) : (
|
||||
<DayTimeline recordings={dayRecs} onSelect={r => { setSelectedRecording(r); setView('detail'); }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayTimeline({ recordings, onSelect }) {
|
||||
// 24-hour timeline with recording bars
|
||||
const HOUR_H = 52;
|
||||
const LABEL_W = 44;
|
||||
const BAR_W = 260;
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function recTop(r) {
|
||||
const h = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||
return h * HOUR_H;
|
||||
}
|
||||
function recHeight(r) {
|
||||
return Math.max((r.durationMinutes / 60) * HOUR_H, 8);
|
||||
}
|
||||
|
||||
function qualityColor(q) {
|
||||
const map = { excellent: 'var(--green)', good: 'var(--teal)', fair: 'var(--amber)', poor: 'var(--red)' };
|
||||
return map[q] || 'var(--text3)';
|
||||
}
|
||||
|
||||
const totalH = 24 * HOUR_H;
|
||||
|
||||
// Density mini-bars per hour (minutes of audio per hour)
|
||||
const densityPerHour = Array(24).fill(0);
|
||||
for (const r of recordings) {
|
||||
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||
const endH = startH + r.durationMinutes / 60;
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const overlapStart = Math.max(h, startH);
|
||||
const overlapEnd = Math.min(h + 1, endH);
|
||||
if (overlapEnd > overlapStart) {
|
||||
densityPerHour[h] += (overlapEnd - overlapStart) * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginTop: 12 }}>
|
||||
{/* Timeline */}
|
||||
<div style={{ position: 'relative', height: totalH, width: LABEL_W + BAR_W + 32, flexShrink: 0 }}>
|
||||
{/* Hour lines + labels */}
|
||||
{hours.map(h => (
|
||||
<div key={h} style={{ position: 'absolute', top: h * HOUR_H, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ width: LABEL_W, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'right', flexShrink: 0, paddingTop: 1 }}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginTop: 6 }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Recording bars */}
|
||||
{recordings.map(r => {
|
||||
const top = recTop(r);
|
||||
const height = recHeight(r);
|
||||
const srcColors = { portable: 'var(--amber)', stationary: 'var(--teal)', phone: 'var(--green)' };
|
||||
const srcColor = srcColors[r.source] || 'var(--text3)';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => onSelect(r)}
|
||||
title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: top + 1,
|
||||
left: LABEL_W + 12,
|
||||
width: BAR_W,
|
||||
height: height - 2,
|
||||
background: `${srcColor}22`,
|
||||
border: `1px solid ${srcColor}66`,
|
||||
borderLeft: `3px solid ${srcColor}`,
|
||||
borderRadius: '0 4px 4px 0',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 8px',
|
||||
overflow: 'hidden',
|
||||
transition: 'background 0.1s',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${srcColor}40`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${srcColor}22`; }}
|
||||
>
|
||||
<span className={`source-pill source-${r.source}`} style={{ fontSize: 9, padding: '1px 5px', flexShrink: 0 }}>{r.source}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}>
|
||||
{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }} className={`q-${r.quality}`}>{r.quality}</span>
|
||||
{r.highlights.length > 0 && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Density strip - clickable to go to recording at that hour */}
|
||||
<div style={{ flexShrink: 0, width: 40 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4, writingMode: 'horizontal-tb' }}>density</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{densityPerHour.map((mins, h) => {
|
||||
const pct = mins / 60;
|
||||
// Find recording that covers this hour
|
||||
const recAtHour = recordings.find(r => {
|
||||
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
|
||||
const endH = startH + r.durationMinutes / 60;
|
||||
return h >= startH && h < endH;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onClick={() => recAtHour && onSelect(recAtHour)}
|
||||
style={{
|
||||
height: HOUR_H,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
cursor: recAtHour ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: Math.round(pct * 36) + 'px',
|
||||
minWidth: pct > 0 ? 2 : 0,
|
||||
background: pct > 0.8 ? 'var(--amber)' : pct > 0.3 ? 'var(--amber-dim)' : 'var(--bg4)',
|
||||
transition: 'width 0.2s, opacity 0.1s',
|
||||
opacity: recAtHour ? 1 : 0.5,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recording list for quick jump */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Jump to</div>
|
||||
{recordings.map(r => {
|
||||
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => onSelect(r)}
|
||||
style={{
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4,
|
||||
padding: '8px 12px', cursor: 'pointer', transition: 'border-color 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--amber)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>{timeStr}</span>
|
||||
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }}>{r.quality}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>{r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB</span>
|
||||
{r.hasTranscript && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>✦ transcript</span>}
|
||||
{r.highlights.length > 0 && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}>◈ {r.highlights.length}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DayView, DayTimeline });
|
||||
Loading…
Add table
Add a link
Reference in a new issue