Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow

This commit is contained in:
2026-01-04 23:50:35 +01:00
parent 71586fd6a8
commit 21ca704abc
5 changed files with 207 additions and 97 deletions

View File

@@ -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>

View File

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