- Migrate from tailwindcss v3.3 to v4.1.18 - Replace @tailwind directives with @import 'tailwindcss' - Move custom colors to @theme block in globals.css - Convert custom utilities to @utility syntax - Update PostCSS config to use @tailwindcss/postcss - Remove autoprefixer (now built-in)
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
'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-xs 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-sm">
|
|
#{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>
|
|
);
|
|
}
|