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

@@ -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)}
/>
)}
</>
);
}