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:
2025-12-25 22:11:50 +01:00
parent afe9197776
commit 75461d7c30
22 changed files with 2050 additions and 146 deletions

152
src/hooks/useBulkScanner.ts Normal file
View 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);
});
}

View File

@@ -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,