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,94 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Sparkles, AlertCircle, Loader2 } from 'lucide-react';
interface BottleSkeletonCardProps {
name?: string;
imageUrl?: string;
processingStatus: 'pending' | 'analyzing' | 'error';
onClick?: () => void;
}
export default function BottleSkeletonCard({
name,
imageUrl,
processingStatus,
onClick
}: BottleSkeletonCardProps) {
const isError = processingStatus === 'error';
const isPending = processingStatus === 'pending';
const isAnalyzing = processingStatus === 'analyzing';
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={onClick}
className={`relative bg-zinc-900 rounded-2xl border overflow-hidden cursor-pointer transition-all ${isError
? 'border-red-500/50 hover:border-red-500'
: 'border-zinc-800 hover:border-orange-600/50'
}`}
>
{/* Image */}
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden">
{imageUrl ? (
<img
src={imageUrl}
alt="Bottle preview"
className={`w-full h-full object-cover ${isError ? 'opacity-50 grayscale' : 'opacity-60'}`}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-16 h-24 bg-zinc-800 rounded-lg animate-pulse" />
</div>
)}
{/* Processing Overlay */}
<div className={`absolute inset-0 flex flex-col items-center justify-center ${isError ? 'bg-red-950/50' : 'bg-black/40'
}`}>
{isError ? (
<>
<AlertCircle size={32} className="text-red-500 mb-2" />
<span className="text-xs font-bold text-red-400">Fehler</span>
</>
) : (
<>
{isPending ? (
<Loader2 size={28} className="text-orange-500 animate-spin mb-2" />
) : (
<Sparkles size={28} className="text-orange-500 animate-pulse mb-2" />
)}
<span className="text-xs font-bold text-zinc-300">
{isPending ? 'Warten...' : 'KI analysiert...'}
</span>
</>
)}
</div>
{/* Status Badge */}
<div className={`absolute top-2 right-2 px-2 py-1 rounded-lg text-[9px] font-black uppercase tracking-widest ${isError
? 'bg-red-500/20 text-red-400'
: isAnalyzing
? 'bg-orange-500/20 text-orange-400'
: 'bg-zinc-800/80 text-zinc-500'
}`}>
{isError ? 'Fehler' : isAnalyzing ? '✨ Analyse' : 'Warte'}
</div>
</div>
{/* Info */}
<div className="p-3">
{/* Skeleton Name */}
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" />
{/* Skeleton Details */}
<div className="flex gap-2">
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" />
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" />
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,296 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, QrCode, Keyboard, Loader2, CheckCircle2, Copy, RefreshCw, Link2 } from 'lucide-react';
import QRCode from 'react-qr-code';
import { generateBuddyCode, redeemBuddyCode, revokeBuddyCode } from '@/services/buddy-link';
import { useI18n } from '@/i18n/I18nContext';
interface BuddyHandshakeProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
type Tab = 'show' | 'enter';
export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHandshakeProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<Tab>('show');
// Show Code Tab State
const [code, setCode] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
// Enter Code Tab State
const [inputCode, setInputCode] = useState('');
const [isRedeeming, setIsRedeeming] = useState(false);
const [redeemError, setRedeemError] = useState<string | null>(null);
const [redeemSuccess, setRedeemSuccess] = useState<string | null>(null);
// Generate code when showing "Show Code" tab
useEffect(() => {
if (isOpen && activeTab === 'show' && !code) {
handleGenerateCode();
}
}, [isOpen, activeTab]);
// Reset state when closing
useEffect(() => {
if (!isOpen) {
setCode(null);
setInputCode('');
setRedeemError(null);
setRedeemSuccess(null);
setCopied(false);
}
}, [isOpen]);
const handleGenerateCode = async () => {
setIsGenerating(true);
const result = await generateBuddyCode();
if (result.success && result.code) {
setCode(result.code);
}
setIsGenerating(false);
};
const handleRefreshCode = async () => {
await revokeBuddyCode();
setCode(null);
handleGenerateCode();
};
const handleCopyCode = () => {
if (code) {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleRedeemCode = async () => {
if (!inputCode.trim()) return;
setIsRedeeming(true);
setRedeemError(null);
setRedeemSuccess(null);
const result = await redeemBuddyCode(inputCode);
if (result.success) {
setRedeemSuccess(`Verbunden mit ${result.buddyName}!`);
setTimeout(() => {
onSuccess?.();
onClose();
}, 1500);
} else {
setRedeemError(result.error || 'Unbekannter Fehler');
}
setIsRedeeming(false);
};
const formatCodeForDisplay = (code: string) => {
// Format as XXX-XXX for better readability
return `${code.slice(0, 3)}-${code.slice(3)}`;
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 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">
<Link2 size={20} className="text-orange-500" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Account verbinden</h2>
<p className="text-xs text-zinc-500">Buddy-Handshake</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white"
>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-zinc-800">
<button
onClick={() => setActiveTab('show')}
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'show'
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<QrCode size={14} />
Mein Code
</button>
<button
onClick={() => setActiveTab('enter')}
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'enter'
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<Keyboard size={14} />
Code eingeben
</button>
</div>
{/* Content */}
<div className="p-6">
{activeTab === 'show' ? (
<div className="flex flex-col items-center gap-6">
{isGenerating ? (
<div className="py-12 flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-orange-500" />
<p className="text-sm text-zinc-500">Generiere Code...</p>
</div>
) : code ? (
<>
{/* QR Code */}
<div className="bg-white p-4 rounded-2xl">
<QRCode
value={`dramlog://buddy/${code}`}
size={180}
level="M"
/>
</div>
{/* Text Code */}
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-zinc-500 uppercase tracking-widest">Oder Code teilen:</p>
<div className="flex items-center gap-2">
<span className="text-3xl font-black tracking-[0.3em] text-white font-mono">
{formatCodeForDisplay(code)}
</span>
<button
onClick={handleCopyCode}
className={`p-2 rounded-lg transition-all ${copied
? 'bg-green-500/20 text-green-500'
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400'
}`}
>
{copied ? <CheckCircle2 size={18} /> : <Copy size={18} />}
</button>
</div>
</div>
{/* Timer/Refresh */}
<div className="flex items-center gap-4 text-xs text-zinc-500">
<span>Gültig für 15 Minuten</span>
<button
onClick={handleRefreshCode}
className="flex items-center gap-1.5 text-orange-500 hover:text-orange-400"
>
<RefreshCw size={12} />
Neuer Code
</button>
</div>
</>
) : (
<div className="py-12 text-center text-zinc-500">
<p>Fehler beim Generieren</p>
<button
onClick={handleGenerateCode}
className="mt-4 text-orange-500 hover:underline"
>
Erneut versuchen
</button>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-6">
<p className="text-sm text-zinc-400 text-center">
Gib den 6-stelligen Code deines Buddies ein:
</p>
{/* Code Input */}
<input
type="text"
value={inputCode}
onChange={(e) => {
const val = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (val.length <= 6) {
setInputCode(val);
setRedeemError(null);
}
}}
placeholder="XXXXXX"
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono"
maxLength={6}
autoFocus
/>
{/* Error Message */}
{redeemError && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 text-center bg-red-500/10 py-2 px-4 rounded-xl"
>
{redeemError}
</motion.p>
)}
{/* Success Message */}
{redeemSuccess && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-center gap-2 text-green-500 bg-green-500/10 py-3 px-4 rounded-xl"
>
<CheckCircle2 size={20} />
<span className="font-bold">{redeemSuccess}</span>
</motion.div>
)}
{/* Submit Button */}
<button
onClick={handleRedeemCode}
disabled={inputCode.length !== 6 || isRedeeming || !!redeemSuccess}
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"
>
{isRedeeming ? (
<>
<Loader2 size={18} className="animate-spin" />
Verbinde...
</>
) : (
<>
<Link2 size={18} />
Verbinden
</>
)}
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -2,9 +2,10 @@
import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Users, UserPlus, Trash2, User, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from './BuddyHandshake';
interface Buddy {
id: string;
@@ -25,6 +26,7 @@ export default function BuddyList() {
}
return false;
});
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
useEffect(() => {
fetchBuddies();
@@ -117,6 +119,17 @@ export default function BuddyList() {
</button>
</form>
{/* Link Account Button */}
<button
onClick={() => setIsHandshakeOpen(true)}
className="w-full mb-6 py-3 bg-zinc-950 hover:bg-zinc-800 border border-zinc-800 hover:border-orange-600/50 rounded-2xl transition-all flex items-center justify-center gap-2 group"
>
<Link2 size={16} className="text-orange-600" />
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-orange-500 transition-colors">
Account verbinden
</span>
</button>
{isLoading ? (
<div className="flex justify-center py-8 text-zinc-500">
<Loader2 size={24} className="animate-spin" />
@@ -173,6 +186,16 @@ export default function BuddyList() {
<span className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest ml-1">{buddies.length} Buddies</span>
</div>
)}
{/* Buddy Handshake Dialog */}
<BuddyHandshake
isOpen={isHandshakeOpen}
onClose={() => setIsHandshakeOpen(false)}
onSuccess={() => {
setIsHandshakeOpen(false);
fetchBuddies();
}}
/>
</div>
);
}

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

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

View File

@@ -5,31 +5,30 @@ import { useEffect } from 'react';
export default function PWARegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
// Register immediately - the page is already loaded when this component mounts
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('[PWA] SW registered:', registration.scope);
// Check for updates
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) return;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
console.log('[SW] New content is available; please refresh.');
} else {
console.log('[SW] Content is cached for offline use.');
}
// Check for updates
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) return;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
console.log('[PWA] New content available; please refresh.');
} else {
console.log('[PWA] Content cached for offline use.');
}
};
}
};
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
};
})
.catch((registrationError) => {
console.error('[PWA] SW registration failed:', registrationError);
});
}
}, []);

View File

@@ -231,6 +231,9 @@ export default function UploadQueue() {
syncInProgress.current = false;
setIsSyncing(false);
setCurrentProgress(null);
// Dispatch event to notify that sync is complete and collection should be refreshed
window.dispatchEvent(new CustomEvent('collection-updated'));
}
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing