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:
280
src/components/BulkScanSheet.tsx
Normal file
280
src/components/BulkScanSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user