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:
94
src/components/BottleSkeletonCard.tsx
Normal file
94
src/components/BottleSkeletonCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
src/components/BuddyHandshake.tsx
Normal file
296
src/components/BuddyHandshake.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user