feat: optimize scan flow with WebP compression and fix admin metrics visibility
This commit is contained in:
@@ -42,15 +42,15 @@ export default function AuthForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md p-8 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="w-full max-w-md p-8 bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-800">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-2xl flex items-center justify-center mb-4">
|
||||
{isLogin ? <LogIn className="text-amber-600" size={32} /> : <UserPlus className="text-amber-600" size={32} />}
|
||||
<div className="w-16 h-16 bg-orange-950/30 rounded-2xl flex items-center justify-center mb-4 border border-orange-900/20">
|
||||
{isLogin ? <LogIn className="text-orange-600" size={32} /> : <UserPlus className="text-orange-600" size={32} />}
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">
|
||||
<h2 className="text-3xl font-black text-white tracking-tight">
|
||||
{isLogin ? 'Willkommen zurück' : 'Vault erstellen'}
|
||||
</h2>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2 text-center text-sm">
|
||||
<p className="text-zinc-400 mt-2 text-center text-sm font-medium">
|
||||
{isLogin
|
||||
? 'Logge dich ein, um auf deine Sammlung zuzugreifen.'
|
||||
: 'Starte heute mit deinem digitalen Whisky-Vault.'}
|
||||
@@ -59,7 +59,7 @@ export default function AuthForm() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 ml-1">E-Mail</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">E-Mail</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<input
|
||||
@@ -68,13 +68,13 @@ export default function AuthForm() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none transition-all dark:text-white"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 ml-1">Passwort</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Passwort</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<input
|
||||
@@ -83,20 +83,20 @@ export default function AuthForm() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none transition-all dark:text-white"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-900/50">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/10 text-red-500 text-xs rounded-lg border border-red-900/20">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 text-sm rounded-lg border border-green-100 dark:border-green-900/50">
|
||||
<div className="p-3 bg-green-900/10 text-green-500 text-xs rounded-lg border border-green-900/20">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
@@ -104,7 +104,7 @@ export default function AuthForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl shadow-lg shadow-amber-600/20 transition-all active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
className="w-full py-4 bg-orange-600 hover:bg-orange-700 text-white font-black uppercase tracking-widest text-xs rounded-xl shadow-lg shadow-orange-950/40 transition-all active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={20} /> : (isLogin ? 'Einloggen' : 'Konto erstellen')}
|
||||
</button>
|
||||
@@ -112,8 +112,9 @@ export default function AuthForm() {
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-sm font-medium text-amber-600 hover:text-amber-700 transition-colors"
|
||||
className="text-xs font-black uppercase tracking-widest text-orange-600 hover:text-orange-500 transition-colors"
|
||||
>
|
||||
{isLogin ? 'Noch kein Konto? Registrieren' : 'Bereits ein Konto? Einloggen'}
|
||||
</button>
|
||||
|
||||
@@ -33,11 +33,11 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
||||
if (!bottle && !loading) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<div className="w-20 h-20 bg-zinc-100 dark:bg-zinc-900 rounded-full flex items-center justify-center text-zinc-400">
|
||||
<div className="w-20 h-20 bg-zinc-900 rounded-full flex items-center justify-center text-zinc-500">
|
||||
<WifiOff size={40} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-2">Flasche nicht verfügbar</h2>
|
||||
<h2 className="text-xl font-black text-zinc-50 mb-2">Flasche nicht verfügbar</h2>
|
||||
<p className="text-zinc-500 text-sm max-w-xs mx-auto">
|
||||
Inhalte konnten nicht geladen werden. Bitte stelle eine Internetverbindung her, um diese Flasche zum ersten Mal zu laden.
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface BottomNavigationProps {
|
||||
onShelf?: () => void;
|
||||
onSearch?: () => void;
|
||||
onProfile?: () => void;
|
||||
onScan: (base64: string) => void;
|
||||
onScan: (file: File) => void;
|
||||
}
|
||||
|
||||
export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan }: BottomNavigationProps) => {
|
||||
@@ -22,11 +22,7 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
onScan(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
onScan(file);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
|
||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User, Clock } from 'lucide-react';
|
||||
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
@@ -19,6 +19,7 @@ import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { shortenCategory } from '@/lib/format';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { processImageForAI } from '@/utils/image-processing';
|
||||
interface CameraCaptureProps {
|
||||
onImageCaptured?: (base64Image: string) => void;
|
||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||
@@ -67,16 +68,32 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
||||
|
||||
// Performance Tracking (Admin only)
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
compression: number;
|
||||
ai: number;
|
||||
prep: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
setIsAdmin(!!data);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('[CameraCapture] Admin check error:', error);
|
||||
}
|
||||
console.log('[CameraCapture] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CameraCapture] checkAdmin failed:', err);
|
||||
}
|
||||
};
|
||||
checkAdmin();
|
||||
@@ -91,6 +108,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
setAnalysisResult(null);
|
||||
setIsQueued(false);
|
||||
setMatchingBottle(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
let fileToProcess = file;
|
||||
@@ -115,7 +133,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
setOriginalFile(fileToProcess);
|
||||
|
||||
const compressedBase64 = await compressImage(fileToProcess);
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(fileToProcess);
|
||||
const endComp = performance.now();
|
||||
|
||||
const compressedBase64 = processed.base64;
|
||||
setPreviewUrl(compressedBase64);
|
||||
|
||||
if (onImageCaptured) {
|
||||
@@ -136,8 +158,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
return;
|
||||
}
|
||||
|
||||
const startAi = performance.now();
|
||||
const response = await magicScan(compressedBase64, aiProvider, locale);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (response.success && response.data) {
|
||||
setAnalysisResult(response.data);
|
||||
|
||||
@@ -158,6 +183,16 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(response.data);
|
||||
}
|
||||
|
||||
const endPrep = performance.now();
|
||||
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
compression: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If scan fails but it looks like a network issue, offer to queue
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
@@ -205,21 +240,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
let imageUrl = undefined;
|
||||
if (originalFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', originalFile);
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.url) {
|
||||
imageUrl = uploadData.url;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl);
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
@@ -247,21 +269,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
let imageUrl = undefined;
|
||||
if (originalFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', originalFile);
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.url) {
|
||||
imageUrl = uploadData.url;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl);
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setLastSavedId(response.data.id);
|
||||
@@ -304,42 +313,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
}
|
||||
};
|
||||
|
||||
const compressImage = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.src = event.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const MAX_WIDTH = 1200;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > MAX_WIDTH) {
|
||||
height = (height * MAX_WIDTH) / width;
|
||||
width = MAX_WIDTH;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas context not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const base64 = canvas.toDataURL('image/jpeg', 0.9);
|
||||
resolve(base64);
|
||||
};
|
||||
img.onerror = reject;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -350,22 +323,22 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-800 transition-all hover:shadow-orange-950/20">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-xl border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
||||
<button
|
||||
onClick={() => setAiProvider('gemini')}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Gemini
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider('mistral')}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Mistral 3 🇪🇺
|
||||
</button>
|
||||
@@ -373,10 +346,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
</div>
|
||||
{activeSession && (
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-amber-600 animate-in slide-in-from-left-2 duration-500">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-orange-600 animate-in slide-in-from-left-2 duration-500">
|
||||
<div className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-500"></span>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-orange-500"></span>
|
||||
</div>
|
||||
{activeSession.name}
|
||||
</div>
|
||||
@@ -384,26 +357,48 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
|
||||
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-800 overflow-hidden flex items-center justify-center bg-zinc-900/50 hover:border-orange-500/50 transition-colors"
|
||||
onClick={triggerUpload}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-zinc-400 group-hover:text-amber-500 transition-colors">
|
||||
<div className="flex flex-col items-center gap-2 text-zinc-600 group-hover:text-orange-500 transition-colors">
|
||||
<Camera size={48} strokeWidth={1.5} />
|
||||
<span className="text-sm font-medium">{t('camera.scanBottle')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-2 left-2 p-2 bg-black/80 backdrop-blur-md rounded-lg border border-orange-500/30 text-[9px] font-mono text-white/90 z-10 pointer-events-none">
|
||||
<div className="font-bold text-orange-500 mb-1 uppercase tracking-tighter">Perf Metrics</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Comp:</span>
|
||||
<span className="text-orange-400">{perfMetrics.compression.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>AI:</span>
|
||||
<span className="text-orange-400">{perfMetrics.ai.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Prep:</span>
|
||||
<span className="text-orange-400">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="pt-1 mt-1 border-t border-white/10 flex justify-between gap-4 font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-white">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-4 text-white p-6 text-center animate-in fade-in duration-300">
|
||||
<div className="relative">
|
||||
<Loader2 size={48} className="animate-spin text-amber-500" />
|
||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||
<Wand2 size={20} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-black uppercase tracking-[0.2em] text-[10px] text-amber-500">Magic Analysis</p>
|
||||
<p className="font-black uppercase tracking-[0.2em] text-[10px] text-orange-500">Magic Analysis</p>
|
||||
<p className="text-sm font-bold">
|
||||
{!navigator.onLine ? 'Offline: Speichere lokal...' : 'Analysiere Flasche...'}
|
||||
</p>
|
||||
@@ -453,7 +448,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{!wbDiscovery && !isDiscovering && (
|
||||
<button
|
||||
onClick={handleDiscoverWb}
|
||||
className="w-full py-3 px-6 bg-amber-50 dark:bg-amber-900/20 text-amber-600 rounded-xl font-bold flex items-center justify-center gap-2 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 transition-all text-sm"
|
||||
className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm"
|
||||
>
|
||||
<Search size={16} />
|
||||
{t('camera.whiskybaseSearch')}
|
||||
@@ -468,17 +463,17 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
|
||||
{wbDiscovery && (
|
||||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-amber-600">
|
||||
<div className="p-4 bg-zinc-950 border border-orange-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-orange-600">
|
||||
<Sparkles size={12} /> {t('camera.wbMatchFound')}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug">
|
||||
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">
|
||||
{wbDiscovery.title}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleLinkWb}
|
||||
className="flex-1 py-2.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
|
||||
className="flex-1 py-2.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
{t('common.link')}
|
||||
</button>
|
||||
@@ -486,7 +481,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
href={wbDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-2.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 transition-colors flex items-center justify-center gap-1"
|
||||
className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<ExternalLink size={12} /> {t('common.check')}
|
||||
</a>
|
||||
@@ -500,7 +495,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
setAnalysisResult(null);
|
||||
setLastSavedId(null);
|
||||
}}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.later')}
|
||||
</button>
|
||||
@@ -509,14 +504,14 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
<Link
|
||||
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`}
|
||||
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
|
||||
className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('camera.toVault')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMatchingBottle(null)}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.saveAnyway')}
|
||||
</button>
|
||||
@@ -538,7 +533,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing || isSaving}
|
||||
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 shadow-black/10' : 'bg-amber-600 hover:bg-amber-700 text-white shadow-amber-600/20'}`}
|
||||
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-100 text-zinc-900 shadow-black/10' : 'bg-orange-600 hover:bg-orange-700 text-white shadow-orange-950/40'}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
@@ -553,7 +548,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
) : previewUrl && analysisResult ? (
|
||||
validatedSessionId ? (
|
||||
<>
|
||||
<Droplets size={20} className="text-amber-500" />
|
||||
<Droplets size={20} className="text-orange-500" />
|
||||
{t('camera.quickTasting')}
|
||||
</>
|
||||
) : (
|
||||
@@ -578,7 +573,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{!previewUrl && !isProcessing && (
|
||||
<button
|
||||
onClick={triggerGallery}
|
||||
className="w-full py-3 px-6 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-200 transition-all text-sm"
|
||||
className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm"
|
||||
>
|
||||
<Upload size={18} />
|
||||
{t('camera.uploadGallery')}
|
||||
@@ -627,20 +622,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{/* Analysis Results Display */}
|
||||
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
|
||||
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full">
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm bg-green-900/10 p-3 rounded-lg w-full border border-green-900/30">
|
||||
<CheckCircle2 size={16} />
|
||||
{t('camera.analysisSuccess')}
|
||||
</div>
|
||||
|
||||
<div className="p-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
|
||||
<div className="p-3 md:p-4 bg-zinc-950 rounded-2xl border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600">
|
||||
<Sparkles size={18} />
|
||||
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.name || '-'}</span>
|
||||
<span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
||||
@@ -675,7 +670,33 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.batch_info}</span>
|
||||
<span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="pt-4 mt-2 border-t border-zinc-900/50 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1">
|
||||
<Clock size={10} /> Performance Data
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Comp:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">AI:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.ai.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Prep:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Total:</span>
|
||||
<span className="text-orange-600 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,13 +107,13 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit border border-zinc-700"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
{t('bottle.editDetails')}
|
||||
</button>
|
||||
{bottle.purchase_price && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-900/10 text-green-400 rounded-xl text-sm font-bold border border-green-900/30 w-fit">
|
||||
<CircleDollarSign size={16} />
|
||||
{t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -152,7 +152,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.distillery}
|
||||
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -161,7 +161,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -172,7 +172,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
step="0.1"
|
||||
value={formData.abv}
|
||||
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -181,7 +181,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.whiskybase_id}
|
||||
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
{discoveryResult && (
|
||||
<div className="mt-2 p-3 bg-zinc-950 border border-orange-500/20 rounded-xl animate-in fade-in slide-in-from-top-2">
|
||||
@@ -220,7 +220,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
href={discoveryResult.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1"
|
||||
className="px-3 py-1.5 bg-zinc-800 text-zinc-400 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center gap-1 border border-zinc-700"
|
||||
>
|
||||
<ExternalLink size={10} /> {t('common.check')}
|
||||
</a>
|
||||
@@ -247,7 +247,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. 2010"
|
||||
value={formData.distilled_at}
|
||||
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. 2022"
|
||||
value={formData.bottled_at}
|
||||
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. Batch 12 oder L-Code"
|
||||
value={formData.batch_info}
|
||||
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Loader2, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { X, Loader2, Sparkles, AlertCircle, Clock } from 'lucide-react';
|
||||
import TastingEditor from './TastingEditor';
|
||||
import SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
@@ -13,52 +13,101 @@ import { saveTasting } from '@/services/save-tasting';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||
|
||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||
|
||||
interface ScanAndTasteFlowProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
base64Image: string | null;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanAndTasteFlowProps) {
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAndTasteFlowProps) {
|
||||
const [state, setState] = useState<FlowState>('IDLE');
|
||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||
const { activeSession } = useSession();
|
||||
const [processedImage, setProcessedImage] = useState<ProcessedImage | null>(null);
|
||||
const [tastingData, setTastingData] = useState<any>(null);
|
||||
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { locale } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [perfMetrics, setPerfMetrics] = useState<{ comp: number; ai: number; prep: number } | null>(null);
|
||||
|
||||
// Admin Check
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) console.error('[ScanFlow] Admin check error:', error);
|
||||
console.log('[ScanFlow] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ScanFlow] checkAdmin failed:', err);
|
||||
}
|
||||
};
|
||||
checkAdmin();
|
||||
}, [supabase]);
|
||||
|
||||
// Trigger scan when open and image provided
|
||||
useEffect(() => {
|
||||
if (isOpen && base64Image) {
|
||||
if (isOpen && imageFile) {
|
||||
console.log('[ScanFlow] Starting handleScan...');
|
||||
handleScan(base64Image);
|
||||
handleScan(imageFile);
|
||||
} else if (!isOpen) {
|
||||
setState('IDLE');
|
||||
setTastingData(null);
|
||||
setBottleMetadata(null);
|
||||
setProcessedImage(null);
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [isOpen, base64Image]);
|
||||
}, [isOpen, imageFile]);
|
||||
|
||||
const handleScan = async (image: string) => {
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
setError(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
const cleanBase64 = image.split(',')[1] || image;
|
||||
console.log('[ScanFlow] Calling magicScan service...');
|
||||
const result = await magicScan(cleanBase64, 'gemini', locale);
|
||||
console.log('[ScanFlow] Starting image processing...');
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(file);
|
||||
const endComp = performance.now();
|
||||
setProcessedImage(processed);
|
||||
|
||||
const cleanBase64 = processed.base64.split(',')[1] || processed.base64;
|
||||
console.log('[ScanFlow] Calling magicScan service with compressed images (WebP)...');
|
||||
|
||||
const startAi = performance.now();
|
||||
const result = await magicScan(cleanBase64, 'gemini', locale);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (result.success && result.data) {
|
||||
console.log('[ScanFlow] magicScan success');
|
||||
setBottleMetadata(result.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
});
|
||||
}
|
||||
|
||||
setState('EDITOR');
|
||||
} else {
|
||||
console.error('[ScanFlow] magicScan failure:', result.error);
|
||||
@@ -72,7 +121,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
};
|
||||
|
||||
const handleSaveTasting = async (formData: any) => {
|
||||
if (!bottleMetadata || !base64Image) return;
|
||||
if (!bottleMetadata || !processedImage) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
@@ -81,8 +130,8 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
// 1. Save Bottle
|
||||
const bottleResult = await saveBottle(bottleMetadata, base64Image, user.id);
|
||||
// 1. Save Bottle - Use compressed base64 for storage as well
|
||||
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
@@ -149,7 +198,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
If we are IDLE but have an image, we are essentially SCANNING (or about to be).
|
||||
If we have no image, we shouldn't really be here, but show error just in case.
|
||||
*/}
|
||||
{(state === 'SCANNING' || (state === 'IDLE' && base64Image)) && (
|
||||
{(state === 'SCANNING' || (state === 'IDLE' && imageFile)) && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
@@ -173,6 +222,25 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="mt-8 p-4 bg-black/40 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[10px] font-mono text-zinc-400 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">Comp</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.comp.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">AI</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.ai.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">Prep</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.prep.toFixed(0)}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -210,12 +278,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
>
|
||||
<TastingEditor
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={base64Image}
|
||||
image={processedImage?.base64 || null}
|
||||
onSave={handleSaveTasting}
|
||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||
activeSessionName={activeSession?.name}
|
||||
activeSessionId={activeSession?.id}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 z-50 p-2 bg-black/60 backdrop-blur-md rounded-lg border border-orange-500/30 text-[9px] font-mono text-white/90 pointer-events-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={10} className="text-orange-500" />
|
||||
<span>Comp: {perfMetrics.comp.toFixed(0)}ms</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>AI: {perfMetrics.ai.toFixed(0)}ms</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>Prep: {perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -246,7 +326,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
balance: tastingData.balance || 85,
|
||||
}}
|
||||
bottleName={bottleMetadata.name || 'Unknown Whisky'}
|
||||
image={base64Image}
|
||||
image={processedImage?.base64 || null}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -206,32 +206,32 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{activeSession && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="bg-amber-600 text-white p-2 rounded-xl">
|
||||
<div className="p-3 bg-orange-950/20 border border-orange-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="bg-orange-600 text-white p-2 rounded-xl">
|
||||
<Sparkles size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-amber-700 dark:text-amber-400">Recording for Session</p>
|
||||
<p className="text-xs font-bold text-amber-900 dark:text-amber-200 truncate">{activeSession.name}</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-orange-500">Recording for Session</p>
|
||||
<p className="text-xs font-bold text-orange-200 truncate">{activeSession.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaletteWarning && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-2xl flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertTriangle size={20} className="text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-2xl flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertTriangle size={20} className="text-orange-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-amber-600">Palette-Checker Warnung</p>
|
||||
<p className="text-xs font-bold text-amber-900 dark:text-amber-200">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-orange-600">Palette-Checker Warnung</p>
|
||||
<p className="text-xs font-bold text-orange-200">
|
||||
Dein letzter Dram war "{lastDramInSession?.name}".
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-800/80 dark:text-amber-400/80 leading-relaxed font-medium">
|
||||
<p className="text-[10px] text-orange-400/80 leading-relaxed font-medium">
|
||||
Da er sehr torfig war und erst vor Kurzem verkostet wurde, könnten deine Geschmacksnerven noch beeinträchtigt sein. Trink am besten etwas Wasser!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaletteWarning(false)}
|
||||
className="text-[9px] font-black uppercase text-amber-600 underline"
|
||||
className="text-[9px] font-black uppercase text-orange-600 underline"
|
||||
>
|
||||
Ignorieren
|
||||
</button>
|
||||
@@ -242,10 +242,10 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Star size={14} className="text-amber-500 fill-amber-500" />
|
||||
<Star size={14} className="text-orange-500 fill-orange-500" />
|
||||
{t('tasting.rating')}
|
||||
</label>
|
||||
<span className="text-2xl font-black text-amber-600 tracking-tighter">{rating}<span className="text-zinc-400 text-sm ml-0.5 font-bold">/100</span></span>
|
||||
<span className="text-2xl font-black text-orange-600 tracking-tighter">{rating}<span className="text-zinc-500 text-sm ml-0.5 font-bold">/100</span></span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -253,7 +253,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
max="100"
|
||||
value={rating}
|
||||
onChange={(e) => setRating(parseInt(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-200 dark:bg-zinc-800 rounded-full appearance-none cursor-pointer accent-amber-600 hover:accent-amber-500 transition-all"
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 hover:accent-orange-500 transition-all"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-zinc-400 font-black uppercase tracking-widest px-1">
|
||||
<span>Swill</span>
|
||||
@@ -264,13 +264,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('tasting.overall')}</label>
|
||||
<div className="grid grid-cols-2 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
|
||||
<div className="grid grid-cols-2 gap-2 p-1 bg-zinc-950 rounded-2xl border border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(false)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${!isSample
|
||||
? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm ring-1 ring-black/5'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Bottle
|
||||
@@ -279,8 +279,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
type="button"
|
||||
onClick={() => setIsSample(true)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${isSample
|
||||
? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm ring-1 ring-black/5'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Sample
|
||||
@@ -290,13 +290,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Nose Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Wind size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.nose')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Aroma & Eindruck</p>
|
||||
@@ -318,20 +318,20 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setNose(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palate Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Utensils size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.palate')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Geschmack & Textur</p>
|
||||
@@ -353,20 +353,20 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setPalate(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Droplets size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.finish')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Abgang & Nachhall</p>
|
||||
@@ -401,7 +401,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setFinish(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +411,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
{buddies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Users size={14} className="text-amber-500" />
|
||||
<Users size={14} className="text-orange-500" />
|
||||
{t('tasting.participants')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -421,8 +421,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
type="button"
|
||||
onClick={() => toggleBuddy(buddy.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all flex items-center gap-1.5 border shadow-sm ${selectedBuddyIds.includes(buddy.id)
|
||||
? 'bg-amber-600 border-amber-600 text-white shadow-amber-600/20'
|
||||
: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-amber-500/50'
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-orange-600/20'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:border-orange-500/50'
|
||||
}`}
|
||||
>
|
||||
{selectedBuddyIds.includes(buddy.id) && <Check size={10} />}
|
||||
@@ -442,7 +442,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10 dark:shadow-amber-900/10"
|
||||
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user