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:
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useState } from 'react';
|
||||
import { Camera, Zap } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import BulkScanSheet from './BulkScanSheet';
|
||||
|
||||
interface FloatingScannerButtonProps {
|
||||
onImageSelected: (base64Image: string) => void;
|
||||
@@ -10,6 +12,9 @@ interface FloatingScannerButtonProps {
|
||||
|
||||
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { activeSession } = useSession();
|
||||
const [isBulkOpen, setIsBulkOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -23,42 +28,116 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleMainClick = () => {
|
||||
if (activeSession) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<motion.button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
whileHover={{ scale: 1.1, translateY: -4 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="relative group p-6 rounded-full bg-orange-600 text-black shadow-lg shadow-orange-950/40 hover:shadow-orange-950/60 transition-all overflow-hidden"
|
||||
>
|
||||
{/* Shine Animation */}
|
||||
<motion.div
|
||||
animate={{
|
||||
x: ['-100%', '100%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatDelay: 3
|
||||
}}
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
|
||||
<>
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
|
||||
{/* Expanded Options */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && activeSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||
className="absolute bottom-20 left-1/2 -translate-x-1/2 flex gap-3"
|
||||
>
|
||||
{/* Single Scan */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-2xl shadow-xl"
|
||||
>
|
||||
<Camera size={24} className="text-white" />
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Einzel</span>
|
||||
</motion.button>
|
||||
|
||||
{/* Pulse ring */}
|
||||
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
|
||||
</motion.button>
|
||||
</div>
|
||||
{/* Bulk Scan */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
setIsBulkOpen(true);
|
||||
}}
|
||||
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-orange-600 rounded-2xl shadow-xl shadow-orange-950/30"
|
||||
>
|
||||
<Zap size={24} className="text-white" />
|
||||
<span className="text-[10px] font-bold text-white/80 uppercase tracking-widest">Bulk</span>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Button */}
|
||||
<motion.button
|
||||
onClick={handleMainClick}
|
||||
whileHover={{ scale: 1.1, translateY: -4 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: isExpanded ? 45 : 0 }}
|
||||
className="relative group p-6 rounded-full bg-orange-600 text-black shadow-lg shadow-orange-950/40 hover:shadow-orange-950/60 transition-all overflow-hidden"
|
||||
>
|
||||
{/* Shine Animation */}
|
||||
{!isExpanded && (
|
||||
<motion.div
|
||||
animate={{
|
||||
x: ['-100%', '100%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatDelay: 3
|
||||
}}
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
|
||||
|
||||
{/* Active Session Indicator */}
|
||||
{activeSession && !isExpanded && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-orange-600 flex items-center justify-center">
|
||||
<Zap size={10} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pulse ring */}
|
||||
{!isExpanded && (
|
||||
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Scan Sheet */}
|
||||
{activeSession && (
|
||||
<BulkScanSheet
|
||||
isOpen={isBulkOpen}
|
||||
onClose={() => setIsBulkOpen(false)}
|
||||
sessionId={activeSession.id}
|
||||
sessionName={activeSession.name}
|
||||
onSuccess={() => setIsBulkOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user