capture: status board with /✓/! indicators, tap-to-retry queued banner

This commit is contained in:
2026-04-29 18:12:50 +00:00
parent 43278686f7
commit 3d0a2168f1
+46 -15
View File
@@ -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)' }} />
{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>