capture: status board with ⏳/✓/! indicators, tap-to-retry queued banner
This commit is contained in:
+48
-17
@@ -85,6 +85,8 @@ async function submitCapture(audio: Blob | null, image: Blob | null): Promise<{
|
||||
}
|
||||
|
||||
type CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error';
|
||||
type CaptureStatus = 'processing' | 'saved' | 'queued';
|
||||
interface CaptureEntry { name: string; status: CaptureStatus; }
|
||||
|
||||
export default function CapturePage() {
|
||||
const [mode, setMode] = useState<CaptureMode>('idle');
|
||||
@@ -92,7 +94,7 @@ export default function CapturePage() {
|
||||
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [recentCaptures, setRecentCaptures] = useState<{name: string}[]>([]);
|
||||
const [recentCaptures, setRecentCaptures] = useState<CaptureEntry[]>([]);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
@@ -112,14 +114,18 @@ export default function CapturePage() {
|
||||
window.addEventListener('focus', retryQueue);
|
||||
window.addEventListener('online', retryQueue);
|
||||
|
||||
// Listen for capture_saved SSE — refresh list when transcription completes
|
||||
let es: EventSource | null = null;
|
||||
function connectSSE() {
|
||||
es = new EventSource(`${API_URL}/api/captures/events`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'capture_saved') loadRecentCaptures();
|
||||
if (data.type === 'capture_saved') {
|
||||
setRecentCaptures(prev => {
|
||||
const withoutProcessing = prev.filter(c => c.status !== 'processing');
|
||||
return [{ name: data.filename.replace('.md', ''), status: 'saved' as CaptureStatus }, ...withoutProcessing].slice(0, 4);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => { es?.close(); setTimeout(connectSSE, 10000); };
|
||||
@@ -136,7 +142,14 @@ export default function CapturePage() {
|
||||
async function loadRecentCaptures() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/captures`);
|
||||
if (res.ok) { const data = await res.json(); setRecentCaptures(data.captures || []); }
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecentCaptures(prev => {
|
||||
const processing = prev.filter(c => c.status === 'processing');
|
||||
const fromApi: CaptureEntry[] = (data.captures || []).map((c: {name: string}) => ({ name: c.name, status: 'saved' as CaptureStatus }));
|
||||
return [...processing, ...fromApi].slice(0, 4);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -149,11 +162,13 @@ export default function CapturePage() {
|
||||
const pending = await getPendingCaptures();
|
||||
for (const item of pending) {
|
||||
const result = await submitCapture(item.audio, item.image);
|
||||
if (result.ok) await removeCapture(item.id);
|
||||
if (result.ok) {
|
||||
await removeCapture(item.id);
|
||||
setRecentCaptures(prev => prev.map(c => c.status === 'queued' ? { ...c, status: 'processing' as CaptureStatus } : c));
|
||||
}
|
||||
}
|
||||
const remaining = await getPendingCaptures();
|
||||
setPendingCount(remaining.length);
|
||||
if (remaining.length < pending.length) loadRecentCaptures();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -233,8 +248,8 @@ export default function CapturePage() {
|
||||
setImagePreview(null);
|
||||
setImageBlob(null);
|
||||
imageBlobRef.current = null;
|
||||
// Optimistic update — show immediately as processing
|
||||
setRecentCaptures(prev => [{ name: `${new Date().toISOString().slice(0,16).replace('T','-').replace(':','-')}-voice ⏳` }, ...prev].slice(0, 4));
|
||||
const ts = new Date().toISOString().slice(0,16).replace('T','-').replace(':','-');
|
||||
setRecentCaptures(prev => [{ name: `${ts}-voice`, status: 'processing' as CaptureStatus }, ...prev].slice(0, 4));
|
||||
setTimeout(() => setMode('idle'), 3000);
|
||||
return;
|
||||
}
|
||||
@@ -248,6 +263,8 @@ export default function CapturePage() {
|
||||
try {
|
||||
await queueCapture(audioBlob, currentImageBlob);
|
||||
await checkPending();
|
||||
const ts = new Date().toISOString().slice(0,16).replace('T','-').replace(':','-');
|
||||
setRecentCaptures(prev => [{ name: `${ts}-voice`, status: 'queued' as CaptureStatus }, ...prev].slice(0, 4));
|
||||
setMode('queued');
|
||||
} catch {
|
||||
setMode('error');
|
||||
@@ -270,12 +287,23 @@ export default function CapturePage() {
|
||||
const isBusy = mode === 'submitting';
|
||||
const hasImage = imageBlob !== null;
|
||||
|
||||
function statusDot(status: CaptureStatus, isImage: boolean) {
|
||||
if (status === 'processing') return { color: '#c8821a', label: '⏳' };
|
||||
if (status === 'queued') return { color: '#a32d2d', label: '!' };
|
||||
if (isImage) return { color: '#c8821a', label: '✓' };
|
||||
return { color: 'var(--accent)', label: '✓' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--bg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between', paddingTop: 'max(48px, env(safe-area-inset-top))', paddingBottom: 'max(32px, env(safe-area-inset-bottom))', paddingLeft: 'max(24px, env(safe-area-inset-left))', paddingRight: 'max(24px, env(safe-area-inset-right))', fontFamily: 'var(--font-mono)' }}>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '10px', letterSpacing: '0.15em', color: 'var(--text3)', textTransform: 'uppercase', margin: 0 }}>Bird — field recorder</p>
|
||||
{pendingCount > 0 && <p style={{ fontSize: '9px', color: '#c8821a', letterSpacing: '0.1em', margin: '4px 0 0', textTransform: 'uppercase' }}>{pendingCount} capture{pendingCount > 1 ? 's' : ''} queued</p>}
|
||||
{pendingCount > 0 && (
|
||||
<button onClick={retryQueue} style={{ fontSize: '9px', color: '#c8821a', letterSpacing: '0.1em', margin: '4px 0 0', textTransform: 'uppercase', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'var(--font-mono)' }}>
|
||||
{pendingCount} capture{pendingCount > 1 ? 's' : ''} queued — tap to retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '28px', width: '100%', maxWidth: '320px' }}>
|
||||
@@ -330,9 +358,9 @@ export default function CapturePage() {
|
||||
{mode === 'idle' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>tap camera or mic</p>}
|
||||
{mode === 'image-selected' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.10em', textTransform: 'uppercase', margin: 0 }}>annotate with voice or save now</p>}
|
||||
{mode === 'recording' && <p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>recording annotation</p>}
|
||||
{mode === 'submitting' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>transcribing...</p>}
|
||||
{mode === 'submitting' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>saving...</p>}
|
||||
{mode === 'saved' && <p style={{ fontSize: '10px', color: 'var(--accent)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>saved ✓</p>}
|
||||
{mode === 'queued' && <p style={{ fontSize: '10px', color: '#c8821a', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>queued — will sync when online</p>}
|
||||
{mode === 'queued' && <p style={{ fontSize: '10px', color: '#c8821a', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>queued — tap header to retry</p>}
|
||||
{mode === 'error' && <p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>error — try again</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,12 +371,15 @@ export default function CapturePage() {
|
||||
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: 0, opacity: 0.5 }}>none yet</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||
{recentCaptures.slice(0, 4).map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', background: 'var(--bg2)', borderRadius: '6px', border: '1px solid var(--border)' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: c.name.includes('image') ? '#c8821a' : 'var(--accent)' }} />
|
||||
<span style={{ fontSize: '10px', color: 'var(--text2)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{recentCaptures.slice(0, 4).map((c, i) => {
|
||||
const dot = statusDot(c.status, c.name.includes('image'));
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', background: 'var(--bg2)', borderRadius: '6px', border: `1px solid ${c.status === 'queued' ? '#f7c1c1' : 'var(--border)'}` }}>
|
||||
<span style={{ fontSize: '10px', flexShrink: 0, color: dot.color }}>{dot.label}</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--text2)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user