feat: Buddy System & Bulk Scanner
- Add Buddy linking via QR code/handshake (buddy_invites table) - Add Bulk Scanner for rapid-fire bottle scanning in sessions - Add processing_status to bottles for background AI analysis - Fix offline OCR with proper tessdata caching in Service Worker - Fix Supabase GoTrueClient singleton warning - Add collection refresh after offline sync completes New components: - BuddyHandshake.tsx - QR code display and code entry - BulkScanSheet.tsx - Camera UI with capture queue - BottleSkeletonCard.tsx - Pending bottle display - useBulkScanner.ts - Queue management hook - buddy-link.ts - Server actions for buddy linking - bulk-scan.ts - Server actions for batch processing
This commit is contained in:
152
src/hooks/useBulkScanner.ts
Normal file
152
src/hooks/useBulkScanner.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { processBulkScan } from '@/services/bulk-scan';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export interface QueuedBottle {
|
||||
tempId: string;
|
||||
blob: Blob;
|
||||
previewUrl: string;
|
||||
status: 'queued' | 'uploading' | 'done' | 'error';
|
||||
ocrPreview?: string;
|
||||
}
|
||||
|
||||
export interface UseBulkScannerReturn {
|
||||
queue: QueuedBottle[];
|
||||
addToQueue: (blob: Blob, ocrHint?: string) => void;
|
||||
removeFromQueue: (tempId: string) => void;
|
||||
clearQueue: () => void;
|
||||
submitToSession: (sessionId: string) => Promise<{ success: boolean; bottleIds?: string[]; error?: string }>;
|
||||
isSubmitting: boolean;
|
||||
progress: { current: number; total: number };
|
||||
}
|
||||
|
||||
export function useBulkScanner(): UseBulkScannerReturn {
|
||||
const [queue, setQueue] = useState<QueuedBottle[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const addToQueue = useCallback((blob: Blob, ocrHint?: string) => {
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
blobUrlsRef.current.add(previewUrl);
|
||||
|
||||
const newItem: QueuedBottle = {
|
||||
tempId: nanoid(),
|
||||
blob,
|
||||
previewUrl,
|
||||
status: 'queued',
|
||||
ocrPreview: ocrHint,
|
||||
};
|
||||
|
||||
setQueue(prev => [...prev, newItem]);
|
||||
}, []);
|
||||
|
||||
const removeFromQueue = useCallback((tempId: string) => {
|
||||
setQueue(prev => {
|
||||
const item = prev.find(i => i.tempId === tempId);
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
blobUrlsRef.current.delete(item.previewUrl);
|
||||
}
|
||||
return prev.filter(i => i.tempId !== tempId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
// Revoke all blob URLs
|
||||
queue.forEach(item => {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
});
|
||||
blobUrlsRef.current.clear();
|
||||
setQueue([]);
|
||||
}, [queue]);
|
||||
|
||||
const submitToSession = useCallback(async (sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
bottleIds?: string[];
|
||||
error?: string
|
||||
}> => {
|
||||
if (queue.length === 0) {
|
||||
return { success: false, error: 'Keine Flaschen in der Warteschlange' };
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setProgress({ current: 0, total: queue.length });
|
||||
|
||||
try {
|
||||
// Convert all blobs to base64 data URLs
|
||||
const imageDataUrls: string[] = [];
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const item = queue[i];
|
||||
|
||||
// Update status to uploading
|
||||
setQueue(prev => prev.map(q =>
|
||||
q.tempId === item.tempId
|
||||
? { ...q, status: 'uploading' as const }
|
||||
: q
|
||||
));
|
||||
|
||||
// Convert blob to data URL
|
||||
const dataUrl = await blobToDataUrl(item.blob);
|
||||
imageDataUrls.push(dataUrl);
|
||||
|
||||
setProgress({ current: i + 1, total: queue.length });
|
||||
}
|
||||
|
||||
// Submit all to server
|
||||
const result = await processBulkScan(sessionId, imageDataUrls);
|
||||
|
||||
if (result.success) {
|
||||
// Mark all as done
|
||||
setQueue(prev => prev.map(q => ({ ...q, status: 'done' as const })));
|
||||
|
||||
// Clear queue after short delay
|
||||
setTimeout(() => {
|
||||
clearQueue();
|
||||
}, 1000);
|
||||
|
||||
return { success: true, bottleIds: result.bottleIds };
|
||||
} else {
|
||||
// Mark all as error
|
||||
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unbekannter Fehler'
|
||||
};
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [queue, clearQueue]);
|
||||
|
||||
return {
|
||||
queue,
|
||||
addToQueue,
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
submitToSession,
|
||||
isSubmitting,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Blob to base64 data URL
|
||||
*/
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -150,11 +150,13 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
const online = isOnline();
|
||||
const tesseractReady = await isTesseractReady();
|
||||
|
||||
// If offline and tesseract not ready, queue immediately
|
||||
// If offline and tesseract not ready, show editor with dummy data
|
||||
// Queue image for later processing when back online
|
||||
if (!online && !tesseractReady) {
|
||||
console.log('[useScanner] Offline + no tesseract cache → queuing');
|
||||
console.log('[useScanner] Offline + no tesseract cache → showing editor with dummy data');
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
|
||||
// Queue for later processing
|
||||
await db.pending_scans.add({
|
||||
temp_id: `temp_${Date.now()}`,
|
||||
imageBase64: processedImage.base64,
|
||||
@@ -163,9 +165,10 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
metadata: dummyMetadata as any,
|
||||
});
|
||||
|
||||
// Show editor with dummy data (status: complete so editor opens!)
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
status: 'queued',
|
||||
status: 'complete',
|
||||
mergedResult: dummyMetadata,
|
||||
perf: {
|
||||
compression: perfCompression,
|
||||
|
||||
Reference in New Issue
Block a user