Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
|
||||
@@ -39,16 +39,26 @@ const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps)
|
||||
export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [showSourcePicker, setShowSourcePicker] = React.useState(false);
|
||||
|
||||
const handleScanClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
const handleCameraClick = () => {
|
||||
cameraInputRef.current?.click();
|
||||
setShowSourcePicker(false);
|
||||
};
|
||||
|
||||
const handleGalleryClick = () => {
|
||||
galleryInputRef.current?.click();
|
||||
setShowSourcePicker(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onScan(file);
|
||||
// Reset inputs
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,15 +69,67 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none">
|
||||
{/* Hidden Input for Scanning */}
|
||||
{/* Hidden Inputs for Scanning */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSourcePicker && (
|
||||
<>
|
||||
{/* Backdrop to close */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-40 pointer-events-auto"
|
||||
onClick={() => setShowSourcePicker(false)}
|
||||
/>
|
||||
|
||||
{/* Source Picker Menu */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.9 }}
|
||||
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-50 pointer-events-auto w-48 bg-zinc-900/95 backdrop-blur-2xl border border-white/10 rounded-3xl p-2 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
onClick={handleCameraClick}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
|
||||
>
|
||||
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
|
||||
<Camera size={18} className="text-orange-500" />
|
||||
<span>Kamera</span>
|
||||
</div>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.6)]" />
|
||||
</button>
|
||||
<div className="h-px bg-white/5 mx-2" />
|
||||
<button
|
||||
onClick={handleGalleryClick}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
|
||||
>
|
||||
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
|
||||
<Library size={18} className="text-zinc-400" />
|
||||
<span>Galerie</span>
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto">
|
||||
{/* Left Items */}
|
||||
<div className="flex-1 flex justify-around">
|
||||
@@ -91,12 +153,12 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
|
||||
{/* Center FAB */}
|
||||
<div className="px-2">
|
||||
<button
|
||||
onClick={handleScanClick}
|
||||
className="w-16 h-16 bg-orange-600 rounded-[30px] flex items-center justify-center text-white shadow-lg shadow-orange-950/40 border border-white/20 active:scale-90 transition-all hover:bg-orange-500 hover:rotate-2 group relative"
|
||||
onClick={() => setShowSourcePicker(!showSourcePicker)}
|
||||
className={`w-16 h-16 rounded-[30px] flex items-center justify-center text-white shadow-xl border active:scale-90 transition-all group relative ${showSourcePicker ? 'bg-zinc-800 border-white/20' : 'bg-orange-600 border-white/20 shadow-orange-950/40 hover:bg-orange-500 hover:rotate-2'}`}
|
||||
aria-label={t('camera.scanBottle')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<Camera size={28} strokeWidth={2.5} />
|
||||
<Camera size={28} strokeWidth={2.5} className={`transition-transform duration-300 ${showSourcePicker ? 'rotate-90 text-orange-500 scale-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
|
||||
|
||||
// Use the Gemini-only scanner hook
|
||||
const scanner = useScanner({
|
||||
@@ -174,18 +175,77 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
scanner.handleScan(file);
|
||||
};
|
||||
|
||||
const performSave = async (formData: any, currentMetadata: BottleMetadata) => {
|
||||
try {
|
||||
// ONLINE: Normal save to Supabase
|
||||
let user;
|
||||
try {
|
||||
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
|
||||
if (!authUser) throw new Error('Nicht autorisiert');
|
||||
user = authUser;
|
||||
} catch (authError: any) {
|
||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
// Re-route back to original handleSaveTasting to trigger offline flow
|
||||
return handleSaveTasting(formData);
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
|
||||
const bottleDataToSave = formData.bottleMetadata || currentMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage!.base64, user.id);
|
||||
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
|
||||
const bottleId = bottleResult.data.id;
|
||||
|
||||
const tastingNote = {
|
||||
...formData,
|
||||
bottle_id: bottleId,
|
||||
};
|
||||
|
||||
const tastingResult = await saveTasting(tastingNote);
|
||||
if (!tastingResult.success) {
|
||||
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
|
||||
}
|
||||
|
||||
setTastingData(tastingNote);
|
||||
setState('RESULT');
|
||||
|
||||
if (onBottleSaved) {
|
||||
onBottleSaved(bottleId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setPendingTastingData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTasting = async (formData: any) => {
|
||||
if (!bottleMetadata || !scanner.processedImage) return;
|
||||
if (!scanner.mergedResult || !scanner.processedImage) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
// If AI is still analyzing, put in "pending" status
|
||||
if (scanner.isAnalyzing) {
|
||||
console.log('[ScanFlow] AI still analyzing - queuing save');
|
||||
setPendingTastingData(formData);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// OFFLINE: Save to IndexedDB queue
|
||||
if (isOffline) {
|
||||
console.log('[ScanFlow] Offline mode - queuing for upload');
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleDataToSave = formData.bottleMetadata || scanner.mergedResult;
|
||||
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
||||
@@ -227,53 +287,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
return;
|
||||
}
|
||||
|
||||
// ONLINE: Normal save to Supabase
|
||||
let user;
|
||||
try {
|
||||
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
|
||||
if (!authUser) throw new Error('Nicht autorisiert');
|
||||
user = authUser;
|
||||
} catch (authError: any) {
|
||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
return handleSaveTasting(formData);
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
// Normal online save
|
||||
await performSave(formData, scanner.mergedResult);
|
||||
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
|
||||
const bottleId = bottleResult.data.id;
|
||||
|
||||
const tastingNote = {
|
||||
...formData,
|
||||
bottle_id: bottleId,
|
||||
};
|
||||
|
||||
const tastingResult = await saveTasting(tastingNote);
|
||||
if (!tastingResult.success) {
|
||||
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
|
||||
}
|
||||
|
||||
setTastingData(tastingNote);
|
||||
setState('RESULT');
|
||||
|
||||
if (onBottleSaved) {
|
||||
onBottleSaved(bottleId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// New Effect: Watch for AI completion if we have a pending save
|
||||
useEffect(() => {
|
||||
if (!scanner.isAnalyzing && pendingTastingData && scanner.mergedResult) {
|
||||
console.log('[ScanFlow] AI finished, triggering pending save');
|
||||
performSave(pendingTastingData, scanner.mergedResult);
|
||||
}
|
||||
}, [scanner.isAnalyzing, pendingTastingData, scanner.mergedResult]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
@@ -479,7 +510,14 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">Speichere Tasting...</h2>
|
||||
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">
|
||||
{scanner.isAnalyzing ? 'Warte auf Analyse...' : 'Speichere Tasting...'}
|
||||
</h2>
|
||||
{scanner.isAnalyzing && (
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-[0.2em] animate-pulse">
|
||||
KI verarbeitet Etikett-Details
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user