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 CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error';
|
||||||
|
type CaptureStatus = 'processing' | 'saved' | 'queued';
|
||||||
|
interface CaptureEntry { name: string; status: CaptureStatus; }
|
||||||
|
|
||||||
export default function CapturePage() {
|
export default function CapturePage() {
|
||||||
const [mode, setMode] = useState<CaptureMode>('idle');
|
const [mode, setMode] = useState<CaptureMode>('idle');
|
||||||
@@ -92,7 +94,7 @@ export default function CapturePage() {
|
|||||||
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [pendingCount, setPendingCount] = 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 mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
@@ -112,14 +114,18 @@ export default function CapturePage() {
|
|||||||
window.addEventListener('focus', retryQueue);
|
window.addEventListener('focus', retryQueue);
|
||||||
window.addEventListener('online', retryQueue);
|
window.addEventListener('online', retryQueue);
|
||||||
|
|
||||||
// Listen for capture_saved SSE — refresh list when transcription completes
|
|
||||||
let es: EventSource | null = null;
|
let es: EventSource | null = null;
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
es = new EventSource(`${API_URL}/api/captures/events`);
|
es = new EventSource(`${API_URL}/api/captures/events`);
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
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 {}
|
} catch {}
|
||||||
};
|
};
|
||||||
es.onerror = () => { es?.close(); setTimeout(connectSSE, 10000); };
|
es.onerror = () => { es?.close(); setTimeout(connectSSE, 10000); };
|
||||||
@@ -136,7 +142,14 @@ export default function CapturePage() {
|
|||||||
async function loadRecentCaptures() {
|
async function loadRecentCaptures() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/captures`);
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,11 +162,13 @@ export default function CapturePage() {
|
|||||||
const pending = await getPendingCaptures();
|
const pending = await getPendingCaptures();
|
||||||
for (const item of pending) {
|
for (const item of pending) {
|
||||||
const result = await submitCapture(item.audio, item.image);
|
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();
|
const remaining = await getPendingCaptures();
|
||||||
setPendingCount(remaining.length);
|
setPendingCount(remaining.length);
|
||||||
if (remaining.length < pending.length) loadRecentCaptures();
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +248,8 @@ export default function CapturePage() {
|
|||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
setImageBlob(null);
|
setImageBlob(null);
|
||||||
imageBlobRef.current = null;
|
imageBlobRef.current = null;
|
||||||
// Optimistic update — show immediately as processing
|
const ts = new Date().toISOString().slice(0,16).replace('T','-').replace(':','-');
|
||||||
setRecentCaptures(prev => [{ name: `${new Date().toISOString().slice(0,16).replace('T','-').replace(':','-')}-voice ⏳` }, ...prev].slice(0, 4));
|
setRecentCaptures(prev => [{ name: `${ts}-voice`, status: 'processing' as CaptureStatus }, ...prev].slice(0, 4));
|
||||||
setTimeout(() => setMode('idle'), 3000);
|
setTimeout(() => setMode('idle'), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,6 +263,8 @@ export default function CapturePage() {
|
|||||||
try {
|
try {
|
||||||
await queueCapture(audioBlob, currentImageBlob);
|
await queueCapture(audioBlob, currentImageBlob);
|
||||||
await checkPending();
|
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');
|
setMode('queued');
|
||||||
} catch {
|
} catch {
|
||||||
setMode('error');
|
setMode('error');
|
||||||
@@ -270,12 +287,23 @@ export default function CapturePage() {
|
|||||||
const isBusy = mode === 'submitting';
|
const isBusy = mode === 'submitting';
|
||||||
const hasImage = imageBlob !== null;
|
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 (
|
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={{ 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' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<p style={{ fontSize: '10px', letterSpacing: '0.15em', color: 'var(--text3)', textTransform: 'uppercase', margin: 0 }}>Bird — field recorder</p>
|
<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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '28px', width: '100%', maxWidth: '320px' }}>
|
<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 === '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 === '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 === '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 === '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>}
|
{mode === 'error' && <p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>error — try again</p>}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: 0, opacity: 0.5 }}>none yet</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||||
{recentCaptures.slice(0, 4).map((c, i) => (
|
{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)' }}>
|
const dot = statusDot(c.status, c.name.includes('image'));
|
||||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: c.name.includes('image') ? '#c8821a' : 'var(--accent)' }} />
|
return (
|
||||||
<span style={{ fontSize: '10px', color: 'var(--text2)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
<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)'}` }}>
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user