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

View File

@@ -0,0 +1,280 @@
'use client';
import React, { useState, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Camera, Trash2, Send, Loader2, Check, AlertCircle, Zap } from 'lucide-react';
import { useBulkScanner } from '@/hooks/useBulkScanner';
interface BulkScanSheetProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionName: string;
onSuccess?: (bottleIds: string[]) => void;
}
export default function BulkScanSheet({
isOpen,
onClose,
sessionId,
sessionName,
onSuccess
}: BulkScanSheetProps) {
const { queue, addToQueue, removeFromQueue, clearQueue, submitToSession, isSubmitting, progress } = useBulkScanner();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [isCameraReady, setIsCameraReady] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Start camera when sheet opens
React.useEffect(() => {
if (isOpen) {
startCamera();
} else {
stopCamera();
}
return () => stopCamera();
}, [isOpen]);
const startCamera = async () => {
try {
setCameraError(null);
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
setIsCameraReady(true);
}
} catch (error) {
console.error('Camera error:', error);
setCameraError('Kamera konnte nicht gestartet werden');
}
};
const stopCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsCameraReady(false);
};
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
addToQueue(blob, `Flasche #${queue.length + 1}`);
}
}, 'image/webp', 0.85);
}, [addToQueue, queue.length]);
const handleSubmit = async () => {
setSubmitError(null);
const result = await submitToSession(sessionId);
if (result.success && result.bottleIds) {
onSuccess?.(result.bottleIds);
setTimeout(() => {
onClose();
}, 500);
} else {
setSubmitError(result.error || 'Fehler beim Hochladen');
}
};
const handleClose = () => {
if (queue.length > 0 && !isSubmitting) {
if (!confirm('Warteschlange verwerfen?')) return;
}
clearQueue();
onClose();
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Zap size={20} className="text-orange-500" />
</div>
<div>
<h2 className="text-sm font-bold text-white uppercase tracking-widest">Bulk Scan</h2>
<p className="text-xs text-zinc-500">{sessionName}</p>
</div>
</div>
<button
onClick={handleClose}
disabled={isSubmitting}
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={24} />
</button>
</div>
{/* Camera View */}
<div className="flex-1 relative overflow-hidden">
{cameraError ? (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
<div className="text-center text-zinc-500">
<AlertCircle size={48} className="mx-auto mb-4 text-red-500" />
<p>{cameraError}</p>
</div>
</div>
) : (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 w-full h-full object-cover"
/>
{!isCameraReady && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
<Loader2 size={32} className="animate-spin text-orange-500" />
</div>
)}
</>
)}
{/* Hidden canvas for capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Capture Button */}
{isCameraReady && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
<button
onClick={captureImage}
disabled={isSubmitting}
className="w-20 h-20 bg-white rounded-full flex items-center justify-center shadow-2xl active:scale-95 transition-transform disabled:opacity-50 ring-4 ring-white/20"
>
<Camera size={32} className="text-zinc-900" />
</button>
</div>
)}
{/* Queue Counter Badge */}
{queue.length > 0 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-4 right-4 bg-orange-600 text-white text-lg font-black px-4 py-2 rounded-full shadow-lg"
>
{queue.length}
</motion.div>
)}
</div>
{/* Queue Strip */}
{queue.length > 0 && (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
className="bg-zinc-900 border-t border-zinc-800 p-4"
>
{/* Thumbnails */}
<div className="flex gap-2 overflow-x-auto pb-3 scrollbar-none">
{queue.map((item, i) => (
<motion.div
key={item.tempId}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="relative shrink-0"
>
<div className={`w-16 h-20 rounded-xl overflow-hidden ring-2 ${item.status === 'done' ? 'ring-green-500' :
item.status === 'error' ? 'ring-red-500' :
item.status === 'uploading' ? 'ring-orange-500 animate-pulse' :
'ring-zinc-700'
}`}>
<img
src={item.previewUrl}
alt={`Bottle ${i + 1}`}
className="w-full h-full object-cover"
/>
{item.status === 'uploading' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2 size={16} className="animate-spin text-orange-500" />
</div>
)}
{item.status === 'done' && (
<div className="absolute inset-0 bg-green-500/30 flex items-center justify-center">
<Check size={20} className="text-white" />
</div>
)}
</div>
{!isSubmitting && item.status === 'queued' && (
<button
onClick={() => removeFromQueue(item.tempId)}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
>
<X size={12} className="text-white" />
</button>
)}
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded">
#{i + 1}
</span>
</motion.div>
))}
</div>
{/* Error Message */}
{submitError && (
<div className="mb-3 text-sm text-red-500 text-center bg-red-500/10 py-2 rounded-xl">
{submitError}
</div>
)}
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={isSubmitting || queue.length === 0}
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 size={18} className="animate-spin" />
Hochladen {progress.current}/{progress.total}...
</>
) : (
<>
<Send size={18} />
{queue.length} Flasche{queue.length !== 1 ? 'n' : ''} zur Session hinzufügen
</>
)}
</button>
</motion.div>
)}
</motion.div>
</AnimatePresence>
);
}