From 3d0a2168f10f3eafcdaf1bb8cc339d8d3a67627a Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Wed, 29 Apr 2026 18:12:50 +0000 Subject: [PATCH] =?UTF-8?q?capture:=20status=20board=20with=20=E2=8F=B3/?= =?UTF-8?q?=E2=9C=93/!=20indicators,=20tap-to-retry=20queued=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/capture/page.tsx | 65 ++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/app/capture/page.tsx b/app/capture/page.tsx index 0686cd8..685dbbb 100644 --- a/app/capture/page.tsx +++ b/app/capture/page.tsx @@ -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('idle'); @@ -92,7 +94,7 @@ export default function CapturePage() { const [imageBlob, setImageBlob] = useState(null); const [countdown, setCountdown] = useState(0); const [pendingCount, setPendingCount] = useState(0); - const [recentCaptures, setRecentCaptures] = useState<{name: string}[]>([]); + const [recentCaptures, setRecentCaptures] = useState([]); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); @@ -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 (

Bird — field recorder

- {pendingCount > 0 &&

{pendingCount} capture{pendingCount > 1 ? 's' : ''} queued

} + {pendingCount > 0 && ( + + )}
@@ -330,9 +358,9 @@ export default function CapturePage() { {mode === 'idle' &&

tap camera or mic

} {mode === 'image-selected' &&

annotate with voice or save now

} {mode === 'recording' &&

recording annotation

} - {mode === 'submitting' &&

transcribing...

} + {mode === 'submitting' &&

saving...

} {mode === 'saved' &&

saved ✓

} - {mode === 'queued' &&

queued — will sync when online

} + {mode === 'queued' &&

queued — tap header to retry

} {mode === 'error' &&

error — try again

}
@@ -343,12 +371,15 @@ export default function CapturePage() {

none yet

) : (
- {recentCaptures.slice(0, 4).map((c, i) => ( -
-
- {c.name} -
- ))} + {recentCaptures.slice(0, 4).map((c, i) => { + const dot = statusDot(c.status, c.name.includes('image')); + return ( +
+ {dot.label} + {c.name} +
+ ); + })}
)}