feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, useState, useEffect } from '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';
|
||||
@@ -8,7 +8,6 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { db } from '@/lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
||||
import { validateSession } from '@/services/validate-session';
|
||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||
@@ -17,8 +16,10 @@ import Link from 'next/link';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { shortenCategory } from '@/lib/format';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
import { processImageForAI } from '@/utils/image-processing';
|
||||
|
||||
interface CameraCaptureProps {
|
||||
onImageCaptured?: (base64Image: string) => void;
|
||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||
@@ -32,14 +33,12 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
const searchParams = useSearchParams();
|
||||
const { activeSession } = useSession();
|
||||
|
||||
// Maintain sessionId from query param for backwards compatibility,
|
||||
// but prefer global activeSession
|
||||
const sessionIdFromUrl = searchParams.get('session_id');
|
||||
const effectiveSessionId = activeSession?.id || sessionIdFromUrl;
|
||||
|
||||
const [validatedSessionId, setValidatedSessionId] = React.useState<string | null>(null);
|
||||
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
if (effectiveSessionId) {
|
||||
const isValid = await validateSession(effectiveSessionId);
|
||||
@@ -67,14 +66,25 @@ 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;
|
||||
aiApi: number;
|
||||
aiParse: number;
|
||||
uploadSize: number;
|
||||
prep: number;
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -85,10 +95,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('[CameraCapture] Admin check error:', error);
|
||||
}
|
||||
console.log('[CameraCapture] Admin status:', !!data);
|
||||
if (error) console.error('[CameraCapture] Admin check error:', error);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -111,122 +118,101 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
try {
|
||||
let fileToProcess = file;
|
||||
|
||||
// HEIC / HEIF Check
|
||||
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' || file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||
|
||||
if (isHeic) {
|
||||
console.log('HEIC detected, converting...');
|
||||
const heic2any = (await import('heic2any')).default;
|
||||
const convertedBlob = await heic2any({
|
||||
blob: file,
|
||||
toType: 'image/jpeg',
|
||||
quality: 0.8
|
||||
});
|
||||
|
||||
const convertedBlob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 });
|
||||
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
setOriginalFile(fileToProcess);
|
||||
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(fileToProcess);
|
||||
const endComp = performance.now();
|
||||
|
||||
const compressedBase64 = processed.base64;
|
||||
setPreviewUrl(compressedBase64);
|
||||
setPreviewUrl(processed.base64);
|
||||
if (onImageCaptured) onImageCaptured(processed.base64);
|
||||
|
||||
if (onImageCaptured) {
|
||||
onImageCaptured(compressedBase64);
|
||||
}
|
||||
|
||||
// Check if Offline
|
||||
if (!navigator.onLine) {
|
||||
console.log('Offline detected. Queuing image...');
|
||||
await db.pending_scans.add({
|
||||
temp_id: crypto.randomUUID(),
|
||||
imageBase64: compressedBase64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider,
|
||||
locale: locale
|
||||
});
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processed.base64)
|
||||
.first();
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[CameraCapture] Existing pending scan found, skipping dual add');
|
||||
} else {
|
||||
await db.pending_scans.add({
|
||||
temp_id: crypto.randomUUID(),
|
||||
imageBase64: processed.base64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider,
|
||||
locale: locale
|
||||
});
|
||||
}
|
||||
setIsQueued(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', processed.file);
|
||||
formData.append('provider', aiProvider);
|
||||
formData.append('locale', locale);
|
||||
|
||||
const startAi = performance.now();
|
||||
const response = await magicScan(formData);
|
||||
const response = await scanLabel(formData);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (response.success && response.data) {
|
||||
setAnalysisResult(response.data);
|
||||
|
||||
if (response.wb_id) {
|
||||
setWbDiscovery({
|
||||
id: response.wb_id,
|
||||
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
|
||||
title: `${response.data.distillery || ''} ${response.data.name || ''}`
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate Check
|
||||
const match = await findMatchingBottle(response.data);
|
||||
if (match) {
|
||||
setMatchingBottle(match);
|
||||
}
|
||||
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(response.data);
|
||||
}
|
||||
if (match) setMatchingBottle(match);
|
||||
if (onAnalysisComplete) onAnalysisComplete(response.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
|
||||
if (isAdmin) {
|
||||
if (isAdmin && response.perf) {
|
||||
setPerfMetrics({
|
||||
compression: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
aiApi: response.perf.apiCall || response.perf.apiDuration || 0,
|
||||
aiParse: response.perf.parsing || response.perf.parseDuration || 0,
|
||||
uploadSize: response.perf.uploadSize || 0,
|
||||
prep: endPrep - startPrep,
|
||||
imagePrep: response.perf.imagePrep,
|
||||
cacheCheck: response.perf.cacheCheck,
|
||||
encoding: response.perf.encoding,
|
||||
modelInit: response.perf.modelInit,
|
||||
validation: response.perf.validation,
|
||||
dbOps: response.perf.dbOps,
|
||||
total: response.perf.total,
|
||||
cacheHit: response.perf.cacheHit
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If scan fails but it looks like a network issue, offer to queue
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
response.error?.toLowerCase().includes('fetch') ||
|
||||
response.error?.toLowerCase().includes('network') ||
|
||||
response.error?.toLowerCase().includes('timeout');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.log('Network issue detected during scan. Queuing...');
|
||||
await db.pending_scans.add({
|
||||
temp_id: crypto.randomUUID(),
|
||||
imageBase64: compressedBase64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider,
|
||||
locale: locale
|
||||
});
|
||||
setIsQueued(true);
|
||||
setError(null); // Clear error as we are queuing
|
||||
} else {
|
||||
setError(response.error || t('camera.analysisError'));
|
||||
if (response.data.is_whisky && response.data.name && response.data.distillery) {
|
||||
enrichData(response.data.name, response.data.distillery, undefined, locale)
|
||||
.then(enrichResult => {
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
setAnalysisResult(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
search_string: enrichResult.data.search_string
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[CameraCapture] Enrichment failed:', err));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Processing failed:', err);
|
||||
// Even on generic error, if we have a compressed image, consider queuing if it looks like connection
|
||||
if (previewUrl && !analysisResult) {
|
||||
setError(t('camera.processingError') + " - " + t('camera.offlineNotice'));
|
||||
} else {
|
||||
setError(t('camera.processingError'));
|
||||
throw new Error(t('camera.analysisError'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Processing failed:', err);
|
||||
setError(err.message || t('camera.processingError'));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -234,28 +220,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleQuickSave = async () => {
|
||||
if (!analysisResult || !previewUrl) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
|
||||
if (!user) throw new Error(t('camera.authRequired'));
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
router.push(url);
|
||||
} else {
|
||||
setError(response.error || t('common.error'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Quick save failed:', err);
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('common.error'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -263,28 +241,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!analysisResult || !previewUrl) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
|
||||
if (!user) throw new Error(t('camera.authRequired'));
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setLastSavedId(response.data.id);
|
||||
if (onSaveComplete) onSaveComplete();
|
||||
} else {
|
||||
setError(response.error || t('common.error'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('common.error'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -299,7 +269,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
abv: analysisResult.abv || undefined,
|
||||
age: analysisResult.age || undefined
|
||||
});
|
||||
|
||||
if (result.success && result.id) {
|
||||
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
|
||||
}
|
||||
@@ -308,30 +277,18 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleLinkWb = async () => {
|
||||
if (!lastSavedId || !wbDiscovery) return;
|
||||
const res = await updateBottle(lastSavedId, {
|
||||
whiskybase_id: wbDiscovery.id
|
||||
});
|
||||
if (res.success) {
|
||||
setWbDiscovery(null);
|
||||
// Show some success feedback if needed, but the button will disappear anyway
|
||||
}
|
||||
const res = await updateBottle(lastSavedId, { whiskybase_id: wbDiscovery.id });
|
||||
if (res.success) setWbDiscovery(null);
|
||||
};
|
||||
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const triggerGallery = () => {
|
||||
galleryInputRef.current?.click();
|
||||
};
|
||||
const triggerUpload = () => fileInputRef.current?.click();
|
||||
const triggerGallery = () => galleryInputRef.current?.click();
|
||||
|
||||
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-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-100 italic">{t('camera.magicShot')}</h2>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
||||
<button
|
||||
@@ -414,22 +371,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
<input type="file" accept="image/*" capture="environment" ref={fileInputRef} onChange={handleCapture} className="hidden" />
|
||||
<input type="file" accept="image/*" ref={galleryInputRef} onChange={handleCapture} className="hidden" />
|
||||
|
||||
{lastSavedId ? (
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
@@ -437,7 +380,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
<CheckCircle2 size={24} className="text-green-500" />
|
||||
{t('camera.saveSuccess')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
@@ -448,258 +390,145 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{t('camera.tastingNow')}
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
|
||||
{!wbDiscovery && !isDiscovering && (
|
||||
<button
|
||||
onClick={handleDiscoverWb}
|
||||
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"
|
||||
>
|
||||
<button onClick={handleDiscoverWb} 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')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isDiscovering && (
|
||||
<div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('camera.searchingWb')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wbDiscovery && (
|
||||
<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-200 line-clamp-2 leading-snug">
|
||||
{wbDiscovery.title}
|
||||
</p>
|
||||
<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-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
{t('common.link')}
|
||||
</button>
|
||||
<a
|
||||
href={wbDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
<button onClick={handleLinkWb} 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>
|
||||
<a href={wbDiscovery.url} target="_blank" rel="noopener noreferrer" 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreviewUrl(null);
|
||||
setAnalysisResult(null);
|
||||
setLastSavedId(null);
|
||||
}}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.later')}
|
||||
</button>
|
||||
<button onClick={() => { setPreviewUrl(null); setAnalysisResult(null); setLastSavedId(null); }} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.later')}</button>
|
||||
</div>
|
||||
) : matchingBottle ? (
|
||||
<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-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"
|
||||
>
|
||||
<Link href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`} 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-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.saveAnyway')}
|
||||
</button>
|
||||
<button onClick={() => setMatchingBottle(null)} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.saveAnyway')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isQueued) {
|
||||
setPreviewUrl(null);
|
||||
} else if (previewUrl && analysisResult) {
|
||||
if (validatedSessionId) {
|
||||
handleQuickSave();
|
||||
} else {
|
||||
handleSave();
|
||||
}
|
||||
} else {
|
||||
triggerUpload();
|
||||
}
|
||||
if (isQueued) setPreviewUrl(null);
|
||||
else if (previewUrl && analysisResult) validatedSessionId ? handleQuickSave() : handleSave();
|
||||
else triggerUpload();
|
||||
}}
|
||||
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-100 text-zinc-900 shadow-black/10' : 'bg-orange-600 hover:bg-orange-700 text-white shadow-orange-950/40'}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
{t('camera.saving')}
|
||||
</>
|
||||
<><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>{t('camera.saving')}</>
|
||||
) : isQueued ? (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.nextBottle')}
|
||||
</>
|
||||
<><CheckCircle2 size={20} />{t('camera.nextBottle')}</>
|
||||
) : previewUrl && analysisResult ? (
|
||||
validatedSessionId ? (
|
||||
<>
|
||||
<Droplets size={20} className="text-orange-500" />
|
||||
{t('camera.quickTasting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.inVault')}
|
||||
</>
|
||||
)
|
||||
validatedSessionId ? <><Droplets size={20} className="text-orange-500" />{t('camera.quickTasting')}</> : <><CheckCircle2 size={20} />{t('camera.inVault')}</>
|
||||
) : previewUrl ? (
|
||||
<>
|
||||
<Upload size={20} />
|
||||
{t('camera.newPhoto')}
|
||||
</>
|
||||
<><Upload size={20} />{t('camera.newPhoto')}</>
|
||||
) : (
|
||||
<>
|
||||
<Camera size={20} />
|
||||
{t('camera.openingCamera')}
|
||||
</>
|
||||
<><Camera size={20} />{t('camera.openingCamera')}</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!previewUrl && !isProcessing && (
|
||||
<button
|
||||
onClick={triggerGallery}
|
||||
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')}
|
||||
<button onClick={triggerGallery} 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')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/10 p-3 rounded-lg w-full">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
<AlertCircle size={16} />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQueued && (
|
||||
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black text-zinc-800 dark:text-zinc-100 italic">Lokal gespeichert!</span>
|
||||
<span className="text-[10px] font-bold text-purple-600 dark:text-purple-400 uppercase tracking-widest">Warteschlange aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchingBottle && !lastSavedId && (
|
||||
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{t('camera.alreadyInVault')}
|
||||
</div>
|
||||
<p className="text-xs text-blue-500/80">
|
||||
{t('camera.alreadyInVaultDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm"><AlertCircle size={16} />{t('camera.alreadyInVault')}</div>
|
||||
<p className="text-xs text-blue-500/80">{t('camera.alreadyInVaultDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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-400 text-sm bg-green-900/10 p-3 rounded-lg w-full border border-green-900/30">
|
||||
<CheckCircle2 size={16} />
|
||||
{t('camera.analysisSuccess')}
|
||||
<CheckCircle2 size={16} />{t('camera.analysisSuccess')}
|
||||
</div>
|
||||
|
||||
<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="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 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>
|
||||
<span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
|
||||
<span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
|
||||
</div>
|
||||
{analysisResult.age && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.ageLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.distilled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.distilled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.bottled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.bottledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.bottled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.nameLabel')}:</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><span className="font-semibold text-right">{analysisResult.distillery || '-'}</span></div>
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.categoryLabel')}:</span><span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span></div>
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.abvLabel')}:</span><span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span></div>
|
||||
{analysisResult.age && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.ageLabel')}:</span><span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span></div>}
|
||||
{analysisResult.vintage && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">Vintage:</span><span className="font-semibold text-right">{analysisResult.vintage}</span></div>}
|
||||
{analysisResult.bottler && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottler:</span><span className="font-semibold">{analysisResult.bottler}</span></div>}
|
||||
{analysisResult.distilled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.distilledLabel')}:</span><span className="font-semibold">{analysisResult.distilled_at}</span></div>}
|
||||
{analysisResult.bottled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.bottledLabel')}:</span><span className="font-semibold">{analysisResult.bottled_at}</span></div>}
|
||||
{analysisResult.batch_info && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.batchLabel')}:</span><span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span></div>}
|
||||
{analysisResult.bottleCode && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottle Code:</span><span className="font-semibold text-zinc-400 font-mono text-xs">{analysisResult.bottleCode}</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="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 className="flex justify-between"><span className="text-zinc-600">CLIENT:</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">({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)</span></div>
|
||||
|
||||
{perfMetrics.cacheHit ? (
|
||||
<div className="col-span-2 text-center py-1">
|
||||
<span className="text-green-500 font-bold text-[11px]">CACHE HIT</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
|
||||
<div className="text-[8px] text-zinc-600 mb-0.5">AI BREAKDOWN:</div>
|
||||
</div>
|
||||
{perfMetrics.imagePrep !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Prep:</span><span className="text-zinc-400 font-mono">{perfMetrics.imagePrep.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.encoding !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Encode:</span><span className="text-zinc-400 font-mono">{perfMetrics.encoding.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.modelInit !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Init:</span><span className="text-zinc-400 font-mono">{perfMetrics.modelInit.toFixed(0)}ms</span></div>}
|
||||
<div className="flex justify-between"><span className="text-orange-400">API:</span><span className="text-orange-400 font-mono font-bold">{perfMetrics.aiApi.toFixed(0)}ms</span></div>
|
||||
{perfMetrics.validation !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Valid:</span><span className="text-zinc-400 font-mono">{perfMetrics.validation.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.dbOps !== undefined && <div className="flex justify-between"><span className="text-zinc-600">DB:</span><span className="text-zinc-400 font-mono">{perfMetrics.dbOps.toFixed(0)}ms</span></div>}
|
||||
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
|
||||
<div className="flex justify-between"><span className="text-zinc-500 font-bold">TOTAL:</span><span className="text-orange-500 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai).toFixed(0)}ms</span></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,13 +7,17 @@ import TastingEditor from './TastingEditor';
|
||||
import SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
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';
|
||||
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||
|
||||
@@ -21,9 +25,10 @@ interface ScanAndTasteFlowProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageFile: File | null;
|
||||
onBottleSaved?: (bottleId: string) => void;
|
||||
}
|
||||
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAndTasteFlowProps) {
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleSaved }: ScanAndTasteFlowProps) {
|
||||
const [state, setState] = useState<FlowState>('IDLE');
|
||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||
const { activeSession } = useSession();
|
||||
@@ -35,13 +40,23 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
const { locale } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
comp: number;
|
||||
aiTotal: number;
|
||||
aiApi: number;
|
||||
aiParse: number;
|
||||
uploadSize: number;
|
||||
prep: number
|
||||
prep: number;
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Admin Check
|
||||
@@ -57,7 +72,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
.maybeSingle();
|
||||
|
||||
if (error) console.error('[ScanFlow] Admin check error:', error);
|
||||
console.log('[ScanFlow] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -67,10 +81,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
checkAdmin();
|
||||
}, [supabase]);
|
||||
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
|
||||
// Trigger scan when open and image provided
|
||||
useEffect(() => {
|
||||
if (isOpen && imageFile) {
|
||||
console.log('[ScanFlow] Starting handleScan...');
|
||||
setAiFallbackActive(false);
|
||||
handleScan(imageFile);
|
||||
} else if (!isOpen) {
|
||||
setState('IDLE');
|
||||
@@ -79,59 +96,143 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
setProcessedImage(null);
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
setAiFallbackActive(false);
|
||||
}
|
||||
}, [isOpen, imageFile]);
|
||||
|
||||
// Online/Offline detection
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
setError(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
console.log('[ScanFlow] Starting image processing...');
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(file);
|
||||
const endComp = performance.now();
|
||||
setProcessedImage(processed);
|
||||
|
||||
console.log('[ScanFlow] Calling magicScan service with FormData (optimized WebP)...');
|
||||
// OFFLINE: Skip AI scan, use dummy metadata
|
||||
if (isOffline) {
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: 0,
|
||||
aiApi: 0,
|
||||
aiParse: 0,
|
||||
uploadSize: processed.file.size,
|
||||
prep: 0,
|
||||
cacheCheck: 0,
|
||||
cacheHit: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ONLINE: Normal AI scan
|
||||
const formData = new FormData();
|
||||
formData.append('file', processed.file);
|
||||
formData.append('provider', 'gemini');
|
||||
formData.append('locale', locale);
|
||||
|
||||
const startAi = performance.now();
|
||||
const result = await magicScan(formData);
|
||||
const result = await scanLabel(formData);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (result.success && result.data) {
|
||||
console.log('[ScanFlow] magicScan success');
|
||||
if (result.raw) {
|
||||
console.log('[ScanFlow] RAW AI RESPONSE:', result.raw);
|
||||
}
|
||||
setBottleMetadata(result.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
if (isAdmin) {
|
||||
if (isAdmin && result.perf) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: endAi - startAi,
|
||||
aiApi: result.perf?.apiDuration || 0,
|
||||
aiParse: result.perf?.parseDuration || 0,
|
||||
uploadSize: result.perf?.uploadSize || 0,
|
||||
prep: endPrep - startPrep
|
||||
aiApi: result.perf.apiCall || result.perf.apiDuration || 0,
|
||||
aiParse: result.perf.parsing || result.perf.parseDuration || 0,
|
||||
uploadSize: result.perf.uploadSize || 0,
|
||||
prep: endPrep - startPrep,
|
||||
imagePrep: result.perf.imagePrep,
|
||||
cacheCheck: result.perf.cacheCheck,
|
||||
encoding: result.perf.encoding,
|
||||
modelInit: result.perf.modelInit,
|
||||
validation: result.perf.validation,
|
||||
dbOps: result.perf.dbOps,
|
||||
total: result.perf.total,
|
||||
cacheHit: result.perf.cacheHit
|
||||
});
|
||||
}
|
||||
|
||||
setState('EDITOR');
|
||||
|
||||
// Step 2: Background Enrichment
|
||||
if (result.data.name && result.data.distillery) {
|
||||
setIsEnriching(true);
|
||||
console.log('[ScanFlow] Starting background enrichment for:', result.data.name);
|
||||
enrichData(result.data.name, result.data.distillery, undefined, locale)
|
||||
.then(enrichResult => {
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
console.log('[ScanFlow] Enrichment data received:', enrichResult.data);
|
||||
setBottleMetadata(prev => {
|
||||
if (!prev) return prev;
|
||||
const updated = {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
search_string: enrichResult.data.search_string
|
||||
};
|
||||
console.log('[ScanFlow] State updated with enriched metadata');
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
console.warn('[ScanFlow] Enrichment result unsuccessful:', enrichResult.error);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[ScanFlow] Enrichment failed:', err))
|
||||
.finally(() => setIsEnriching(false));
|
||||
}
|
||||
} else if (result.isAiError) {
|
||||
console.warn('[ScanFlow] AI Analysis failed, falling back to offline mode');
|
||||
setIsOffline(true);
|
||||
setAiFallbackActive(true);
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
} else {
|
||||
console.error('[ScanFlow] magicScan failure:', result.error);
|
||||
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
|
||||
throw new Error(result.error || 'Fehler bei der Analyse.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[ScanFlow] handleScan error:', err);
|
||||
|
||||
// Check if this is a network error (offline)
|
||||
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError') || err.message?.includes('ERR_INTERNET_DISCONNECTED')) {
|
||||
console.log('[ScanFlow] Network error detected - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
|
||||
// Use dummy metadata for offline scan
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Other errors
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
}
|
||||
@@ -144,11 +245,119 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
// OFFLINE: Save to IndexedDB queue (skip auth check)
|
||||
if (isOffline) {
|
||||
console.log('[ScanFlow] Offline mode - queuing for upload');
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// 1. Save Bottle - Use compressed base64 for storage as well
|
||||
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
// Save pending scan with metadata
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
// Store bottle metadata in a custom field
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
|
||||
// Save pending tasting linked to temp bottle
|
||||
await db.pending_tastings.add({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
session_id: activeSession?.id,
|
||||
rating: formData.rating,
|
||||
nose_notes: formData.nose_notes,
|
||||
palate_notes: formData.palate_notes,
|
||||
finish_notes: formData.finish_notes,
|
||||
is_sample: formData.is_sample,
|
||||
buddy_ids: formData.buddy_ids,
|
||||
tag_ids: formData.tag_ids,
|
||||
},
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setTastingData(formData);
|
||||
setState('RESULT');
|
||||
setIsSaving(false);
|
||||
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 auth fails due to network, treat as offline
|
||||
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);
|
||||
|
||||
// Save to queue instead
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
|
||||
await db.pending_tastings.add({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
session_id: activeSession?.id,
|
||||
rating: formData.rating,
|
||||
nose_notes: formData.nose_notes,
|
||||
palate_notes: formData.palate_notes,
|
||||
finish_notes: formData.finish_notes,
|
||||
is_sample: formData.is_sample,
|
||||
buddy_ids: formData.buddy_ids,
|
||||
tag_ids: formData.tag_ids,
|
||||
},
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setTastingData(formData);
|
||||
setState('RESULT');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
// Other auth errors
|
||||
throw authError;
|
||||
}
|
||||
|
||||
// 1. Save Bottle - Use edited metadata if provided
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
@@ -168,6 +377,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
|
||||
setTastingData(tastingNote);
|
||||
setState('RESULT');
|
||||
|
||||
// Trigger bottle list refresh in parent
|
||||
if (onBottleSaved) {
|
||||
onBottleSaved(bottleId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
@@ -250,7 +464,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">AI Engine</p>
|
||||
{perfMetrics.aiApi === 0 ? (
|
||||
{perfMetrics.cacheHit ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
|
||||
<p className="text-[7px] opacity-40 mt-1">DB RESULTS</p>
|
||||
@@ -259,8 +473,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
<>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</p>
|
||||
<div className="flex flex-col gap-0.5 mt-1 text-[7px] opacity-60">
|
||||
<span>API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
||||
<span>Parse: {perfMetrics.aiParse.toFixed(0)}ms</span>
|
||||
{perfMetrics.imagePrep !== undefined && <span>Prep: {perfMetrics.imagePrep.toFixed(0)}ms</span>}
|
||||
{perfMetrics.encoding !== undefined && <span>Encode: {perfMetrics.encoding.toFixed(0)}ms</span>}
|
||||
{perfMetrics.modelInit !== undefined && <span>Init: {perfMetrics.modelInit.toFixed(0)}ms</span>}
|
||||
<span className="text-orange-400">API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
||||
{perfMetrics.validation !== undefined && <span>Valid: {perfMetrics.validation.toFixed(0)}ms</span>}
|
||||
{perfMetrics.dbOps !== undefined && <span>DB: {perfMetrics.dbOps.toFixed(0)}ms</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -307,6 +525,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
exit={{ y: -50, opacity: 0 }}
|
||||
className="flex-1 w-full h-full flex flex-col min-h-0"
|
||||
>
|
||||
{isOffline && (
|
||||
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
|
||||
<p className="text-xs font-bold text-orange-500 uppercase tracking-wider">
|
||||
{aiFallbackActive
|
||||
? 'KI-Dienst nicht erreichbar. Nutze Platzhalter-Daten; Anreicherung erfolgt automatisch im Hintergrund.'
|
||||
: 'Offline Modus - Daten werden hochgeladen wenn du online bist'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TastingEditor
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={processedImage?.base64 || null}
|
||||
@@ -314,6 +544,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||
activeSessionName={activeSession?.name}
|
||||
activeSessionId={activeSession?.id}
|
||||
isEnriching={isEnriching}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||
@@ -326,12 +557,21 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">AI:</span>
|
||||
{perfMetrics.aiApi === 0 ? (
|
||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT ⚡</span>
|
||||
{perfMetrics.cacheHit ? (
|
||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</span>
|
||||
<span className="text-zinc-600 ml-1">(API: {perfMetrics.aiApi.toFixed(0)}ms / Pars: {perfMetrics.aiParse.toFixed(0)}ms)</span>
|
||||
<span className="text-zinc-600 ml-1 text-[10px]">
|
||||
(
|
||||
{perfMetrics.imagePrep !== undefined && `Prep:${perfMetrics.imagePrep.toFixed(0)} `}
|
||||
{perfMetrics.encoding !== undefined && `Enc:${perfMetrics.encoding.toFixed(0)} `}
|
||||
{perfMetrics.modelInit !== undefined && `Init:${perfMetrics.modelInit.toFixed(0)} `}
|
||||
<span className="text-orange-400 font-bold">API:{perfMetrics.aiApi.toFixed(0)}</span>
|
||||
{perfMetrics.validation !== undefined && ` Val:${perfMetrics.validation.toFixed(0)}`}
|
||||
{perfMetrics.dbOps !== undefined && ` DB:${perfMetrics.dbOps.toFixed(0)}`}
|
||||
)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,10 @@ interface TagSelectorProps {
|
||||
label?: string;
|
||||
suggestedTagNames?: string[];
|
||||
suggestedCustomTagNames?: string[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames, isLoading }: TagSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -28,7 +29,6 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
[]
|
||||
);
|
||||
|
||||
const isLoading = tags === undefined;
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
const tagList = tags || [];
|
||||
@@ -57,9 +57,9 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
const selectedTags = (tags || []).filter(t => selectedTagIds.includes(t.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{label && (
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
|
||||
<label className="text-[10px] font-black text-zinc-500 uppercase tracking-[0.15em] block">{label}</label>
|
||||
)}
|
||||
|
||||
{/* Selected Tags */}
|
||||
@@ -70,36 +70,36 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-full text-[10px] font-bold uppercase tracking-tight shadow-sm shadow-orange-600/20 animate-in fade-in zoom-in-95"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-xl text-[10px] font-black uppercase tracking-tight shadow-lg shadow-orange-950/20 animate-in fade-in zoom-in-95 hover:bg-orange-500 transition-colors"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
<X size={12} />
|
||||
<X size={12} strokeWidth={3} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] italic text-zinc-500 font-medium">Noch keine Tags gewählt...</span>
|
||||
<span className="text-[10px] italic text-zinc-600 font-medium">Noch keine Tags gewählt...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Suggest */}
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 text-zinc-500" size={14} />
|
||||
<Search className="absolute left-3.5 text-zinc-500" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Tag suchen oder hinzufügen..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-zinc-900 border border-zinc-800 rounded-xl text-xs focus:ring-1 focus:ring-orange-600 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
{isCreating && (
|
||||
<Loader2 className="absolute right-3 animate-spin text-orange-600" size={14} />
|
||||
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<div className="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-50 w-full mt-2 bg-zinc-950 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredTags.length > 0 ? (
|
||||
filteredTags.map(tag => (
|
||||
<button
|
||||
@@ -109,19 +109,19 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
onToggleTag(tag.id);
|
||||
setSearch('');
|
||||
}}
|
||||
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-300 hover:bg-zinc-800/50 flex items-center justify-between border-b border-zinc-800 last:border-0"
|
||||
className="w-full px-4 py-3 text-left text-[11px] font-bold text-zinc-300 hover:bg-zinc-900 flex items-center justify-between border-b border-zinc-900 last:border-0 transition-colors"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" />}
|
||||
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" strokeWidth={3} />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTag}
|
||||
className="w-full px-4 py-3 text-left text-xs font-bold text-orange-600 hover:bg-orange-950/10 flex items-center gap-2"
|
||||
className="w-full px-4 py-4 text-left text-[11px] font-bold text-orange-500 hover:bg-orange-500/5 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<Plus size={14} strokeWidth={3} />
|
||||
"{search}" als neuen Tag hinzufügen
|
||||
</button>
|
||||
)}
|
||||
@@ -131,36 +131,43 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{!search && suggestedTagNames && suggestedTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-orange-500">
|
||||
<Sparkles size={10} /> {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'}
|
||||
{!search && (isLoading || (suggestedTagNames && suggestedTagNames.length > 0)) && (
|
||||
<div className="space-y-2 py-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-orange-600">
|
||||
{isLoading ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} className="fill-orange-600/20" />}
|
||||
{isLoading ? 'Analysiere Aromen...' : 'KI Vorschläge'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tags || [])
|
||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
||||
.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-2.5 py-1 rounded-lg bg-orange-950/20 text-orange-500 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-900/30 transition-colors border border-orange-900/50 flex items-center gap-1.5"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isLoading ? (
|
||||
[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-7 w-16 bg-zinc-900 animate-pulse rounded-xl" />
|
||||
))
|
||||
) : (
|
||||
(tags || [])
|
||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames?.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
||||
.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Custom Suggestions */}
|
||||
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-zinc-500">
|
||||
<div className="space-y-2 py-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||
Dominante Note anlegen?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedCustomTagNames
|
||||
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
|
||||
.map(name => (
|
||||
@@ -177,7 +184,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
}
|
||||
setCreatingSuggestion(null);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-lg bg-zinc-900/50 text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
|
||||
className="px-3 py-1.5 rounded-xl bg-zinc-950/50 text-zinc-500 text-[10px] font-black uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{creatingSuggestion === name ? <Loader2 size={10} className="animate-spin" /> : <Plus size={10} />}
|
||||
{name}
|
||||
@@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions Chips (limit to 6 random or most common) */}
|
||||
{/* Suggestions Chips */}
|
||||
{!search && (tags || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{(tags || [])
|
||||
@@ -198,7 +205,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-2.5 py-1 rounded-lg bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-colors border border-zinc-800"
|
||||
className="px-2.5 py-1.5 rounded-xl bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-300 transition-colors border border-zinc-800/50"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap } from 'lucide-react';
|
||||
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap, Loader2 } from 'lucide-react';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import TagSelector from './TagSelector';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||
|
||||
interface TastingEditorProps {
|
||||
bottleMetadata: BottleMetadata;
|
||||
@@ -17,9 +18,10 @@ interface TastingEditorProps {
|
||||
onOpenSessions: () => void;
|
||||
activeSessionName?: string;
|
||||
activeSessionId?: string;
|
||||
isEnriching?: boolean;
|
||||
}
|
||||
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId }: TastingEditorProps) {
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching }: TastingEditorProps) {
|
||||
const { t } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [rating, setRating] = useState(85);
|
||||
@@ -38,6 +40,27 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
|
||||
|
||||
// Editable bottle metadata
|
||||
const [bottleName, setBottleName] = useState(bottleMetadata.name || '');
|
||||
const [bottleDistillery, setBottleDistillery] = useState(bottleMetadata.distillery || '');
|
||||
const [bottleAbv, setBottleAbv] = useState(bottleMetadata.abv?.toString() || '');
|
||||
const [bottleAge, setBottleAge] = useState(bottleMetadata.age?.toString() || '');
|
||||
const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
|
||||
|
||||
const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
|
||||
const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
|
||||
const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
|
||||
const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
|
||||
const [bottleDistilledAt, setBottleDistilledAt] = useState(bottleMetadata.distilled_at || '');
|
||||
const [bottleBottledAt, setBottleBottledAt] = useState(bottleMetadata.bottled_at || '');
|
||||
const [showBottleDetails, setShowBottleDetails] = useState(false);
|
||||
|
||||
// Whiskybase discovery
|
||||
const [whiskybaseId, setWhiskybaseId] = useState(bottleMetadata.whiskybaseId || '');
|
||||
const [whiskybaseDiscovery, setWhiskybaseDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
||||
const [isDiscoveringWb, setIsDiscoveringWb] = useState(false);
|
||||
const [whiskybaseError, setWhiskybaseError] = useState<string | null>(null);
|
||||
const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
|
||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||
|
||||
@@ -100,6 +123,42 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
}
|
||||
}, [lastDramInSession]);
|
||||
|
||||
// Automatic Whiskybase discovery when details are expanded
|
||||
useEffect(() => {
|
||||
const searchWhiskybase = async () => {
|
||||
if (showBottleDetails && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
|
||||
setIsDiscoveringWb(true);
|
||||
try {
|
||||
const result = await discoverWhiskybaseId({
|
||||
name: bottleMetadata.name || '',
|
||||
distillery: bottleMetadata.distillery ?? undefined,
|
||||
abv: bottleMetadata.abv ?? undefined,
|
||||
age: bottleMetadata.age ?? undefined,
|
||||
batch_info: bottleMetadata.batch_info ?? undefined,
|
||||
distilled_at: bottleMetadata.distilled_at ?? undefined,
|
||||
bottled_at: bottleMetadata.bottled_at ?? undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.id) {
|
||||
setWhiskybaseDiscovery({ id: result.id, url: result.url, title: result.title });
|
||||
setWhiskybaseId(result.id);
|
||||
setWhiskybaseError(null);
|
||||
} else {
|
||||
// No results found
|
||||
setWhiskybaseError('Keine Ergebnisse gefunden');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[TastingEditor] Whiskybase discovery failed:', err);
|
||||
setWhiskybaseError(err.message || 'Suche fehlgeschlagen');
|
||||
} finally {
|
||||
setIsDiscoveringWb(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
searchWhiskybase();
|
||||
}, [showBottleDetails]); // Only trigger when details are expanded
|
||||
|
||||
const toggleBuddy = (id: string) => {
|
||||
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
|
||||
};
|
||||
@@ -118,27 +177,29 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
taste: tasteScore,
|
||||
finish: finishScore,
|
||||
complexity: complexityScore,
|
||||
balance: balanceScore
|
||||
balance: balanceScore,
|
||||
// Edited bottle metadata
|
||||
bottleMetadata: {
|
||||
...bottleMetadata,
|
||||
name: bottleName || bottleMetadata.name,
|
||||
distillery: bottleDistillery || bottleMetadata.distillery,
|
||||
abv: bottleAbv ? parseFloat(bottleAbv) : bottleMetadata.abv,
|
||||
age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
|
||||
category: bottleCategory || bottleMetadata.category,
|
||||
vintage: bottleVintage || null,
|
||||
bottler: bottleBottler || null,
|
||||
batch_info: bottleBatchInfo || null,
|
||||
bottleCode: bottleCode || null,
|
||||
distilled_at: bottleDistilledAt || null,
|
||||
bottled_at: bottleBottledAt || null,
|
||||
whiskybaseId: whiskybaseId || null,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col w-full bg-zinc-950 h-full overflow-hidden">
|
||||
{/* Top Context Bar - Flex Child 1 */}
|
||||
<div className="w-full bg-zinc-900 border-b border-zinc-800 shrink-0">
|
||||
<button
|
||||
onClick={onOpenSessions}
|
||||
className="max-w-2xl mx-auto w-full p-6 flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500">Kontext</p>
|
||||
<p className="font-bold text-zinc-50 leading-none mt-1">{activeSessionName || 'Trinkst du in Gesellschaft?'}</p>
|
||||
</div>
|
||||
<ChevronDown size={20} className="text-orange-500 group-hover:translate-y-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Scrollable Content - Flex Child 2 */}
|
||||
{/* Main Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
|
||||
{/* Palette Warning */}
|
||||
@@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-3xl font-bold text-orange-600 mb-1 truncate leading-none uppercase tracking-tight">
|
||||
{bottleMetadata.distillery || 'Destillerie'}
|
||||
{bottleDistillery || 'Destillerie'}
|
||||
</h1>
|
||||
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleMetadata.name || 'Unbekannter Malt'}</p>
|
||||
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleName || 'Unbekannter Malt'}</p>
|
||||
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest leading-none">
|
||||
{bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `• ${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `• ${bottleMetadata.age}y` : ''}
|
||||
{bottleCategory || 'Whisky'} {bottleAbv ? `• ${bottleAbv}%` : ''} {bottleAge ? `• ${bottleAge}y` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Bottle Details */}
|
||||
<div className="bg-zinc-900/50 rounded-2xl border border-zinc-800 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowBottleDetails(!showBottleDetails)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-zinc-900/70 transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||
Bottle Details
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-zinc-500 transition-transform ${showBottleDetails ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showBottleDetails && (
|
||||
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
|
||||
{/* Name */}
|
||||
<div className="mt-3">
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Flaschenname
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleName}
|
||||
onChange={(e) => setBottleName(e.target.value)}
|
||||
placeholder="e.g. 12 Year Old"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distillery */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Destillerie
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleDistillery}
|
||||
onChange={(e) => setBottleDistillery(e.target.value)}
|
||||
placeholder="e.g. Lagavulin"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* ABV */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Alkohol (ABV %)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={bottleAbv}
|
||||
onChange={(e) => setBottleAbv(e.target.value)}
|
||||
placeholder="43.0"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/* Age */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Alter (Jahre)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={bottleAge}
|
||||
onChange={(e) => setBottleAge(e.target.value)}
|
||||
placeholder="12"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Kategorie
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleCategory}
|
||||
onChange={(e) => setBottleCategory(e.target.value)}
|
||||
placeholder="e.g. Single Malt"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/* Vintage */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Vintage
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleVintage}
|
||||
onChange={(e) => setBottleVintage(e.target.value)}
|
||||
placeholder="e.g. 2007"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottler */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottler
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBottler}
|
||||
onChange={(e) => setBottleBottler(e.target.value)}
|
||||
placeholder="e.g. Independent Bottler"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distilled At */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Distilled At
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleDistilledAt}
|
||||
onChange={(e) => setBottleDistilledAt(e.target.value)}
|
||||
placeholder="e.g. 2007"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottled At */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottled At
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBottledAt}
|
||||
onChange={(e) => setBottleBottledAt(e.target.value)}
|
||||
placeholder="e.g. 2024"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch Info */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Batch Info
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBatchInfo}
|
||||
onChange={(e) => setBottleBatchInfo(e.target.value)}
|
||||
placeholder="e.g. Oloroso Sherry Cask"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottle Code */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottle Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleCode}
|
||||
onChange={(e) => setBottleCode(e.target.value)}
|
||||
placeholder="e.g. WB271235"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Whiskybase Discovery */}
|
||||
{isDiscoveringWb && (
|
||||
<div className="flex items-center gap-2 p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<Loader2 size={16} className="animate-spin text-orange-500" />
|
||||
<span className="text-xs text-zinc-400">Searching Whiskybase...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{whiskybaseDiscovery && (
|
||||
<div className="p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[9px] font-bold uppercase tracking-wider text-orange-500 mb-1">
|
||||
Whiskybase Found
|
||||
</p>
|
||||
<p className="text-xs text-zinc-200 truncate">
|
||||
{whiskybaseDiscovery.title}
|
||||
</p>
|
||||
<a
|
||||
href={whiskybaseDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-orange-500 hover:underline mt-1 inline-block"
|
||||
>
|
||||
View on Whiskybase →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 font-mono">
|
||||
ID: {whiskybaseDiscovery.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whiskybase Error */}
|
||||
{whiskybaseError && !whiskybaseDiscovery && !isDiscoveringWb && (
|
||||
<div className="p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
|
||||
<p className="text-xs text-zinc-500">
|
||||
{whiskybaseError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Selector */}
|
||||
<button
|
||||
onClick={onOpenSessions}
|
||||
className="w-full p-6 bg-zinc-900/50 rounded-2xl border border-zinc-800 flex items-center justify-between group hover:bg-zinc-900/70 hover:border-orange-500/30 transition-all"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">
|
||||
Session
|
||||
</p>
|
||||
<p className="font-bold text-zinc-50 leading-none">
|
||||
{activeSessionName || 'Trinkst du in Gesellschaft?'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeSessionName && (
|
||||
<Users size={18} className="text-orange-500" />
|
||||
)}
|
||||
<ChevronDown size={18} className="text-zinc-500 group-hover:text-orange-500 group-hover:translate-y-0.5 transition-all" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Rating Slider */}
|
||||
<div className="space-y-6 bg-zinc-900 p-8 rounded-3xl border border-zinc-800 shadow-inner relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||
@@ -252,6 +552,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -289,6 +590,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -383,19 +685,19 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Footer - Flex Child 3 */}
|
||||
<div className="w-full p-8 bg-zinc-950 border-t border-zinc-800 shrink-0">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={handleInternalSave}
|
||||
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-xl active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Send size={20} />
|
||||
{t('tasting.saveTasting')}
|
||||
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-bold text-white/60">{rating}</div>
|
||||
</button>
|
||||
</div>
|
||||
{/* Fixed/Sticky Footer for Save Action */}
|
||||
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={handleInternalSave}
|
||||
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-2xl shadow-orange-950/40 active:scale-[0.98] transition-all hover:bg-orange-500"
|
||||
>
|
||||
<Send size={20} />
|
||||
{t('tasting.saveTasting')}
|
||||
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-bold text-white/60">{rating}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -439,18 +439,21 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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} /> : (
|
||||
<>
|
||||
<Send size={16} />
|
||||
{t('tasting.saveTasting')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* Sticky Save Button Container */}
|
||||
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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-2xl shadow-black/50 border border-white/5"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : (
|
||||
<>
|
||||
<Send size={16} />
|
||||
{t('tasting.saveTasting')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,31 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
|
||||
import TastingNoteForm from './TastingNoteForm';
|
||||
|
||||
// Helper to convert base64 to FormData
|
||||
function base64ToFormData(base64: string, filename: string = 'image.webp'): FormData {
|
||||
const arr = base64.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/webp';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
const file = new File([u8arr], filename, { type: mime });
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export default function UploadQueue() {
|
||||
const supabase = createClient();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
@@ -23,9 +41,12 @@ export default function UploadQueue() {
|
||||
|
||||
const totalInQueue = pendingScans.length + pendingTastings.length;
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
|
||||
const syncInProgress = React.useRef(false);
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (syncInProgress.current || !navigator.onLine) return;
|
||||
|
||||
syncInProgress.current = true;
|
||||
setIsSyncing(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
@@ -36,61 +57,139 @@ export default function UploadQueue() {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Sync Scans (Magic Shots)
|
||||
for (const item of pendingScans) {
|
||||
// 1. Sync Scans (Magic Shots) - Two-Step Flow
|
||||
// We use a transaction to "claim" items currently not being synced by another tab/instance
|
||||
const scansToSync = await db.transaction('rw', db.pending_scans, async () => {
|
||||
const all = await db.pending_scans.toArray();
|
||||
const now = Date.now();
|
||||
const available = all.filter(i => {
|
||||
if (i.syncing) return false;
|
||||
// Exponential backoff: don't retry immediately if it failed before
|
||||
if (i.attempts && i.attempts > 0) {
|
||||
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000); // Max 30s
|
||||
const lastAttempt = i.timestamp; // We use timestamp for simplicity or add last_attempt
|
||||
// For now we trust timestamp + backoff if timestamp is updated on fail
|
||||
return (now - i.timestamp) > backoff;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const item of available) {
|
||||
await db.pending_scans.update(item.id!, { syncing: 1 });
|
||||
}
|
||||
return available;
|
||||
});
|
||||
|
||||
for (const item of scansToSync) {
|
||||
const itemId = `scan-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
|
||||
setCurrentProgress({ id: itemId, status: 'OCR Analyse...' });
|
||||
try {
|
||||
const analysis = await magicScan(item.imageBase64, item.provider, item.locale);
|
||||
if (analysis.success && analysis.data) {
|
||||
const bottleData = analysis.data;
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
||||
if (save.success && save.data) {
|
||||
const newBottleId = save.data.id;
|
||||
let bottleData;
|
||||
|
||||
// Reconcile pending tastings linked to this temp_id
|
||||
if (item.temp_id) {
|
||||
const linkedTastings = await db.pending_tastings
|
||||
.where('pending_bottle_id')
|
||||
.equals(item.temp_id)
|
||||
.toArray();
|
||||
// Check if this is an offline scan with pre-filled metadata
|
||||
// CRITICAL: If name is empty, it's placeholder metadata and needs OCR enrichment
|
||||
if (item.metadata && item.metadata.name && item.metadata.name.trim().length > 0) {
|
||||
console.log('[UploadQueue] Valid offline metadata found - skipping OCR');
|
||||
bottleData = item.metadata;
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Offline-Scan...' });
|
||||
} else {
|
||||
console.log('[UploadQueue] No valid metadata - running OCR analysis');
|
||||
// Normal online scan - perform AI analysis
|
||||
// Step 1: Fast OCR
|
||||
const formData = base64ToFormData(item.imageBase64);
|
||||
const ocrResult = await scanLabel(formData);
|
||||
|
||||
for (const lt of linkedTastings) {
|
||||
await db.pending_tastings.update(lt.id!, {
|
||||
bottle_id: newBottleId,
|
||||
pending_bottle_id: undefined
|
||||
});
|
||||
if (ocrResult.success && ocrResult.data) {
|
||||
bottleData = ocrResult.data;
|
||||
|
||||
// Step 2: Background enrichment (before saving)
|
||||
if (bottleData.is_whisky && bottleData.name && bottleData.distillery) {
|
||||
setCurrentProgress({ id: itemId, status: 'Enrichment...' });
|
||||
const enrichResult = await enrichData(
|
||||
bottleData.name,
|
||||
bottleData.distillery,
|
||||
undefined,
|
||||
item.locale
|
||||
);
|
||||
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
// Merge enrichment data into bottle data
|
||||
bottleData = {
|
||||
...bottleData,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setCompletedItems(prev => [...prev.slice(-4), {
|
||||
id: itemId,
|
||||
name: bottleData.name || 'Unbekannter Whisky',
|
||||
bottleId: newBottleId,
|
||||
type: 'scan'
|
||||
}]);
|
||||
await db.pending_scans.delete(item.id!);
|
||||
} else {
|
||||
throw new Error(ocrResult.error || 'Analyse fehlgeschlagen');
|
||||
}
|
||||
} else {
|
||||
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Step 3: Save bottle with all data
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
||||
|
||||
if (save.success && save.data) {
|
||||
const newBottleId = save.data.id;
|
||||
|
||||
// Reconcile pending tastings linked to this temp_id
|
||||
if (item.temp_id) {
|
||||
const linkedTastings = await db.pending_tastings
|
||||
.where('pending_bottle_id')
|
||||
.equals(item.temp_id)
|
||||
.toArray();
|
||||
|
||||
for (const lt of linkedTastings) {
|
||||
await db.pending_tastings.update(lt.id!, {
|
||||
bottle_id: newBottleId,
|
||||
pending_bottle_id: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setCompletedItems(prev => [...prev.slice(-4), {
|
||||
id: itemId,
|
||||
name: bottleData.name || 'Unbekannter Whisky',
|
||||
bottleId: newBottleId,
|
||||
type: 'scan'
|
||||
}]);
|
||||
await db.pending_scans.delete(item.id!);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
console.error('Scan sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
|
||||
// Wait a bit before next
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||
// Unmark as syncing on failure, update attempts and timestamp for backoff
|
||||
await db.pending_scans.update(item.id!, {
|
||||
syncing: 0,
|
||||
attempts: (item.attempts || 0) + 1,
|
||||
last_error: errorMessage,
|
||||
timestamp: Date.now() // Update timestamp to use for backoff
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync Tastings
|
||||
for (const item of pendingTastings) {
|
||||
// If it still has a pending_bottle_id, it means the scan hasn't synced yet.
|
||||
// We SKIP this tasting and wait for the scan to finish in a future loop.
|
||||
if (item.pending_bottle_id) {
|
||||
continue;
|
||||
const tastingsToSync = await db.transaction('rw', db.pending_tastings, async () => {
|
||||
const all = await db.pending_tastings.toArray();
|
||||
const now = Date.now();
|
||||
const available = all.filter(i => {
|
||||
if (i.syncing || i.pending_bottle_id) return false;
|
||||
// Exponential backoff
|
||||
if (i.attempts && i.attempts > 0) {
|
||||
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000);
|
||||
return (now - new Date(i.tasted_at).getTime()) > backoff;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const item of available) {
|
||||
await db.pending_tastings.update(item.id!, { syncing: 1 });
|
||||
}
|
||||
return available;
|
||||
});
|
||||
|
||||
for (const item of tastingsToSync) {
|
||||
const itemId = `tasting-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
||||
try {
|
||||
@@ -112,36 +211,61 @@ export default function UploadQueue() {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
console.error('Tasting sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||
await db.pending_tastings.update(item.id!, {
|
||||
syncing: 0,
|
||||
attempts: (item.attempts || 0) + 1,
|
||||
last_error: errorMessage
|
||||
// Note: we use tasted_at or add a last_attempt for backoff.
|
||||
// For now let's just use the tried attempts as a counter and a fixed wait.
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Global Sync Error:', err);
|
||||
} finally {
|
||||
syncInProgress.current = false;
|
||||
setIsSyncing(false);
|
||||
setCurrentProgress(null);
|
||||
}
|
||||
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
|
||||
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('Online! Waiting 2s for network stability...');
|
||||
setTimeout(() => {
|
||||
syncQueue();
|
||||
}, 2000);
|
||||
console.log('Online! Syncing in 2s...');
|
||||
setTimeout(syncQueue, 2000);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
|
||||
// Initial check if we are online and have items
|
||||
// Initial check: only trigger if online and items exist,
|
||||
// and we aren't already syncing.
|
||||
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
|
||||
syncQueue();
|
||||
// we use a small timeout to debounce background sync
|
||||
const timer = setTimeout(syncQueue, 3000);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, [totalInQueue, syncQueue, isSyncing]);
|
||||
// Trigger when the presence of items changes or online status
|
||||
}, [syncQueue, totalInQueue > 0]); // Removed isSyncing to break the loop
|
||||
|
||||
// Clear stale syncing flags on mount
|
||||
useEffect(() => {
|
||||
const clearStaleFlags = async () => {
|
||||
await db.transaction('rw', [db.pending_scans, db.pending_tastings], async () => {
|
||||
await db.pending_scans.where('syncing').equals(1).modify({ syncing: 0 });
|
||||
await db.pending_tastings.where('syncing').equals(1).modify({ syncing: 0 });
|
||||
});
|
||||
};
|
||||
clearStaleFlags();
|
||||
}, []);
|
||||
|
||||
if (totalInQueue === 0) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user