Files

554 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
const DB_NAME = 'bird-capture-queue';
const STORE_NAME = 'pending';
const MAX_IMAGE_PX = 1568; // Claude vision optimal max dimension
// IndexedDB queue
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function queueCapture(formData: FormData) {
const db = await openDB();
const id = Date.now().toString();
const audio = formData.get('audio') as Blob | null;
const image = formData.get('image') as Blob | null;
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({
id,
audio: audio || null,
image: image || null,
timestamp: new Date().toISOString(),
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getPendingCaptures(): Promise<any[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function removeCapture(id: string) {
const db = await openDB();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Resize image client-side before upload
async function resizeImage(file: File): Promise<Blob> {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const { width, height } = img;
const maxDim = MAX_IMAGE_PX;
let w = width, h = height;
if (w > maxDim || h > maxDim) {
if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; }
else { w = Math.round(w * maxDim / h); h = maxDim; }
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.88);
};
img.src = url;
});
}
async function submitCapture(audio: Blob | null, image: Blob | null): Promise<boolean> {
try {
const form = new FormData();
if (audio) form.append('audio', audio, 'capture.webm');
if (image) form.append('image', image, 'capture.jpg');
const res = await fetch(`${API_URL}/api/capture`, { method: 'POST', body: form });
return res.ok;
} catch {
return false;
}
}
type CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error';
export default function CapturePage() {
const [mode, setMode] = useState<CaptureMode>('idle');
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const [countdown, setCountdown] = useState(0);
const [pendingCount, setPendingCount] = useState(0);
const [recentCaptures, setRecentCaptures] = useState<{name: string}[]>([]);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const wakeLockRef = useRef<any>(null);
const countdownRef = useRef<any>(null);
const mimeTypeRef = useRef('audio/webm');
const fileInputRef = useRef<HTMLInputElement>(null);
const MAX_SECONDS = 120; // shorter for annotation — 2 min max
useEffect(() => {
loadRecentCaptures();
checkPending();
window.addEventListener('focus', retryQueue);
window.addEventListener('online', retryQueue);
return () => {
window.removeEventListener('focus', retryQueue);
window.removeEventListener('online', retryQueue);
};
}, []);
async function loadRecentCaptures() {
try {
const res = await fetch(`${API_URL}/api/captures`);
if (res.ok) {
const data = await res.json();
setRecentCaptures(data.captures || []);
}
} catch {}
}
async function checkPending() {
try {
const p = await getPendingCaptures();
setPendingCount(p.length);
} catch {}
}
async function retryQueue() {
try {
const pending = await getPendingCaptures();
for (const item of pending) {
const ok = await submitCapture(item.audio, item.image);
if (ok) await removeCapture(item.id);
}
const remaining = await getPendingCaptures();
setPendingCount(remaining.length);
if (remaining.length < pending.length) loadRecentCaptures();
} catch {}
}
// ── Image selection ────────────────────────────────────────────────────────
function handleCameraClick() {
fileInputRef.current?.click();
}
async function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const resized = await resizeImage(file);
setImageBlob(resized);
const preview = URL.createObjectURL(resized);
setImagePreview(preview);
setMode('image-selected');
// Reset input so same file can be reselected
e.target.value = '';
}
function clearImage() {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
setMode('idle');
stopRecording();
}
// ── Voice recording ────────────────────────────────────────────────────────
async function acquireWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as any).wakeLock.request('screen');
}
} catch {}
}
function releaseWakeLock() {
wakeLockRef.current?.release();
wakeLockRef.current = null;
}
async function startRecording() {
try {
if (!streamRef.current || streamRef.current.getTracks().every(t => t.readyState === 'ended')) {
streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
: MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : 'audio/ogg';
mimeTypeRef.current = mimeType;
chunksRef.current = [];
const mr = new MediaRecorder(streamRef.current, { mimeType });
mr.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = async () => {
streamRef.current?.getTracks().forEach(t => t.stop());
streamRef.current = null;
if (chunksRef.current.length === 0) {
// No audio recorded — submit image only
await submitFinal(null);
return;
}
const audioBlob = new Blob(chunksRef.current, { type: mimeTypeRef.current });
await submitFinal(audioBlob);
};
mr.start(1000);
mediaRecorderRef.current = mr;
setMode('recording');
setCountdown(MAX_SECONDS);
await acquireWakeLock();
countdownRef.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) { stopRecording(); return 0; }
return prev - 1;
});
}, 1000);
} catch {
setMode('error');
setTimeout(() => setMode(imageBlob ? 'image-selected' : 'idle'), 3000);
}
}
function stopRecording() {
if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; }
releaseWakeLock();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
}
// ── Submission ─────────────────────────────────────────────────────────────
async function submitFinal(audioBlob: Blob | null) {
setMode('submitting');
const ok = await submitCapture(audioBlob, imageBlob);
if (ok) {
setMode('saved');
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
await loadRecentCaptures();
setTimeout(() => setMode('idle'), 3000);
} else {
try {
const form = new FormData();
if (audioBlob) form.append('audio', audioBlob, 'capture.webm');
if (imageBlob) form.append('image', imageBlob, 'capture.jpg');
await queueCapture(form);
await checkPending();
setMode('queued');
} catch {
setMode('error');
}
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
setTimeout(() => setMode('idle'), 4000);
}
}
async function submitImageOnly() {
await submitFinal(null);
}
// ── Tap handler for voice button ───────────────────────────────────────────
function handleVoiceTap() {
if (mode === 'recording') stopRecording();
else if (mode === 'image-selected') startRecording();
else if (mode === 'idle') startRecording(); // voice-only capture
}
const isRecording = mode === 'recording';
const isBusy = mode === 'submitting';
const hasImage = imageBlob !== null;
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)',
}}>
{/* Header */}
<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>
)}
</div>
{/* Main area */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '28px', width: '100%', maxWidth: '320px' }}>
{/* Image preview */}
{imagePreview && (
<div style={{ position: 'relative', width: '100%' }}>
<img
src={imagePreview}
alt="capture preview"
style={{
width: '100%',
maxHeight: '240px',
objectFit: 'cover',
borderRadius: '12px',
border: '1px solid var(--border)',
display: 'block',
}}
/>
<button
onClick={clearImage}
style={{
position: 'absolute', top: '8px', right: '8px',
background: 'rgba(0,0,0,0.6)', border: 'none',
borderRadius: '50%', width: '28px', height: '28px',
color: '#fff', cursor: 'pointer', fontSize: '16px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>×</button>
</div>
)}
{/* Waveform — shows during recording */}
{isRecording && (
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', height: '32px' }}>
{[4,8,16,6,24,10,32,14,20,8,12,28,6,18,4].map((h, i) => (
<div key={i} style={{
width: '2px', height: `${h}px`, background: 'var(--accent)', borderRadius: '1px',
animation: `wave ${0.8 + (i % 4) * 0.2}s ease-in-out infinite alternate`,
}} />
))}
</div>
)}
{/* Capture buttons row */}
<div style={{ display: 'flex', gap: '24px', alignItems: 'center', justifyContent: 'center' }}>
{/* Camera button — always visible, opens image picker */}
<button
onClick={handleCameraClick}
disabled={isBusy || isRecording}
style={{
width: '72px', height: '72px', borderRadius: '50%',
border: `1.5px solid ${hasImage ? 'var(--accent)' : 'var(--border2)'}`,
background: hasImage ? 'var(--accent-light)' : 'var(--bg2)',
cursor: isBusy || isRecording ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s', touchAction: 'manipulation',
opacity: isBusy || isRecording ? 0.5 : 1,
}}
aria-label="Take photo or choose image"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke={hasImage ? 'var(--accent)' : 'var(--text3)'} strokeWidth="1.5">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</button>
{/* Mic / submit button — large center */}
<button
onPointerUp={handleVoiceTap}
disabled={isBusy}
style={{
width: '120px', height: '120px', borderRadius: '50%',
border: `1.5px solid ${isRecording ? '#a32d2d' : 'var(--accent)'}`,
background: isRecording ? 'rgba(163,45,45,0.08)' : 'var(--bg2)',
cursor: isBusy ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.3s', touchAction: 'manipulation',
animation: isBusy ? 'none' : isRecording ? 'recordPulse 1.5s ease-in-out infinite' : 'idlePulse 3s ease-in-out infinite',
}}
aria-label={isRecording ? 'Stop recording' : 'Record voice annotation'}
>
{isBusy ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--accent)' }}>
<circle cx="12" cy="12" r="3" opacity="0.6">
<animate attributeName="opacity" values="0.6;1;0.6" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
) : isRecording ? (
<div style={{ width: '20px', height: '20px', background: '#a32d2d', borderRadius: '4px' }} />
) : (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5">
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4z"/>
<path d="M5 11a7 7 0 0 0 14 0"/>
<line x1="12" y1="18" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
)}
</button>
{/* Submit image-only button — visible only when image selected and not recording */}
{hasImage && !isRecording && !isBusy ? (
<button
onClick={submitImageOnly}
style={{
width: '72px', height: '72px', borderRadius: '50%',
border: '1.5px solid var(--accent)',
background: 'var(--accent-light)',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s', touchAction: 'manipulation',
}}
aria-label="Save image without annotation"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
) : (
<div style={{ width: '72px', height: '72px' }} />
)}
</div>
{/* Recording progress */}
{isRecording && (
<div style={{ width: '100%' }}>
<div style={{ width: '100%', height: '2px', background: 'var(--border)', borderRadius: '1px', overflow: 'hidden', marginBottom: '6px' }}>
<div style={{
height: '100%',
width: `${((MAX_SECONDS - countdown) / MAX_SECONDS) * 100}%`,
background: countdown <= 15 ? '#a32d2d' : 'var(--accent)',
borderRadius: '1px', transition: 'width 1s linear',
}} />
</div>
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: 0, textAlign: 'center' }}>
{(() => {
const rm = Math.floor(countdown / 60);
const rs = countdown % 60;
return `${rm}:${rs.toString().padStart(2,'0')} left`;
})()}
</p>
</div>
)}
{/* Status */}
<div style={{ textAlign: 'center', minHeight: '20px' }}>
{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 }}>
{hasImage ? 'processing image...' : 'transcribing...'}
</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 === 'error' && (
<p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>
error try again
</p>
)}
</div>
</div>
{/* Recent captures */}
<div style={{ width: '100%', maxWidth: '320px' }}>
<p style={{ fontSize: '9px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: '0 0 8px' }}>
recent captures
</p>
{recentCaptures.length === 0 ? (
<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>
))}
</div>
)}
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelected}
style={{ display: 'none' }}
/>
<style>{`
@keyframes idlePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(45,158,107,0); }
50% { box-shadow: 0 0 0 12px rgba(45,158,107,0.06); }
}
@keyframes recordPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(163,45,45,0); }
50% { box-shadow: 0 0 0 16px rgba(163,45,45,0.08); }
}
@keyframes wave {
from { transform: scaleY(0.4); }
to { transform: scaleY(1.4); }
}
`}</style>
</div>
);
}