Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow
This commit is contained in:
@@ -11,6 +11,9 @@ export async function GET(request: Request) {
|
|||||||
await supabase.auth.exchangeCodeForSession(code);
|
await supabase.auth.exchangeCodeForSession(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer SITE_URL from env, fall back to request origin (local dev)
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || requestUrl.origin;
|
||||||
|
|
||||||
// URL to redirect to after sign in process completes
|
// URL to redirect to after sign in process completes
|
||||||
return NextResponse.redirect(requestUrl.origin);
|
return NextResponse.redirect(`${baseUrl}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react';
|
import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
@@ -39,16 +39,26 @@ const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps)
|
|||||||
export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) {
|
export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const galleryInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const [showSourcePicker, setShowSourcePicker] = React.useState(false);
|
||||||
|
|
||||||
const handleScanClick = () => {
|
const handleCameraClick = () => {
|
||||||
fileInputRef.current?.click();
|
cameraInputRef.current?.click();
|
||||||
|
setShowSourcePicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGalleryClick = () => {
|
||||||
|
galleryInputRef.current?.click();
|
||||||
|
setShowSourcePicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
onScan(file);
|
onScan(file);
|
||||||
|
// Reset inputs
|
||||||
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,15 +69,67 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none">
|
<div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none">
|
||||||
{/* Hidden Input for Scanning */}
|
{/* Hidden Inputs for Scanning */}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={cameraInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={galleryInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showSourcePicker && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop to close */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-40 pointer-events-auto"
|
||||||
|
onClick={() => setShowSourcePicker(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Source Picker Menu */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.9 }}
|
||||||
|
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-50 pointer-events-auto w-48 bg-zinc-900/95 backdrop-blur-2xl border border-white/10 rounded-3xl p-2 shadow-2xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleCameraClick}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
|
||||||
|
<Camera size={18} className="text-orange-500" />
|
||||||
|
<span>Kamera</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.6)]" />
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-white/5 mx-2" />
|
||||||
|
<button
|
||||||
|
onClick={handleGalleryClick}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
|
||||||
|
<Library size={18} className="text-zinc-400" />
|
||||||
|
<span>Galerie</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto">
|
<div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto">
|
||||||
{/* Left Items */}
|
{/* Left Items */}
|
||||||
<div className="flex-1 flex justify-around">
|
<div className="flex-1 flex justify-around">
|
||||||
@@ -91,12 +153,12 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
|
|||||||
{/* Center FAB */}
|
{/* Center FAB */}
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleScanClick}
|
onClick={() => setShowSourcePicker(!showSourcePicker)}
|
||||||
className="w-16 h-16 bg-orange-600 rounded-[30px] flex items-center justify-center text-white shadow-lg shadow-orange-950/40 border border-white/20 active:scale-90 transition-all hover:bg-orange-500 hover:rotate-2 group relative"
|
className={`w-16 h-16 rounded-[30px] flex items-center justify-center text-white shadow-xl border active:scale-90 transition-all group relative ${showSourcePicker ? 'bg-zinc-800 border-white/20' : 'bg-orange-600 border-white/20 shadow-orange-950/40 hover:bg-orange-500 hover:rotate-2'}`}
|
||||||
aria-label={t('camera.scanBottle')}
|
aria-label={t('camera.scanBottle')}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<Camera size={28} strokeWidth={2.5} />
|
<Camera size={28} strokeWidth={2.5} className={`transition-transform duration-300 ${showSourcePicker ? 'rotate-90 text-orange-500 scale-90' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
||||||
const [isEnriching, setIsEnriching] = useState(false);
|
const [isEnriching, setIsEnriching] = useState(false);
|
||||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||||
|
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
|
||||||
|
|
||||||
// Use the Gemini-only scanner hook
|
// Use the Gemini-only scanner hook
|
||||||
const scanner = useScanner({
|
const scanner = useScanner({
|
||||||
@@ -174,18 +175,77 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
scanner.handleScan(file);
|
scanner.handleScan(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const performSave = async (formData: any, currentMetadata: BottleMetadata) => {
|
||||||
|
try {
|
||||||
|
// ONLINE: Normal save to Supabase
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
|
||||||
|
if (!authUser) throw new Error('Nicht autorisiert');
|
||||||
|
user = authUser;
|
||||||
|
} catch (authError: any) {
|
||||||
|
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||||
|
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||||
|
setIsOffline(true);
|
||||||
|
// Re-route back to original handleSaveTasting to trigger offline flow
|
||||||
|
return handleSaveTasting(formData);
|
||||||
|
}
|
||||||
|
throw authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottleDataToSave = formData.bottleMetadata || currentMetadata;
|
||||||
|
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage!.base64, user.id);
|
||||||
|
|
||||||
|
if (!bottleResult.success || !bottleResult.data) {
|
||||||
|
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottleId = bottleResult.data.id;
|
||||||
|
|
||||||
|
const tastingNote = {
|
||||||
|
...formData,
|
||||||
|
bottle_id: bottleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tastingResult = await saveTasting(tastingNote);
|
||||||
|
if (!tastingResult.success) {
|
||||||
|
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTastingData(tastingNote);
|
||||||
|
setState('RESULT');
|
||||||
|
|
||||||
|
if (onBottleSaved) {
|
||||||
|
onBottleSaved(bottleId);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
setState('ERROR');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
setPendingTastingData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveTasting = async (formData: any) => {
|
const handleSaveTasting = async (formData: any) => {
|
||||||
if (!bottleMetadata || !scanner.processedImage) return;
|
if (!scanner.mergedResult || !scanner.processedImage) return;
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// If AI is still analyzing, put in "pending" status
|
||||||
|
if (scanner.isAnalyzing) {
|
||||||
|
console.log('[ScanFlow] AI still analyzing - queuing save');
|
||||||
|
setPendingTastingData(formData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// OFFLINE: Save to IndexedDB queue
|
// OFFLINE: Save to IndexedDB queue
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
console.log('[ScanFlow] Offline mode - queuing for upload');
|
console.log('[ScanFlow] Offline mode - queuing for upload');
|
||||||
const tempId = `temp_${Date.now()}`;
|
const tempId = `temp_${Date.now()}`;
|
||||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
const bottleDataToSave = formData.bottleMetadata || scanner.mergedResult;
|
||||||
|
|
||||||
const existingScan = await db.pending_scans
|
const existingScan = await db.pending_scans
|
||||||
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
||||||
@@ -227,53 +287,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONLINE: Normal save to Supabase
|
// Normal online save
|
||||||
let user;
|
await performSave(formData, scanner.mergedResult);
|
||||||
try {
|
|
||||||
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
|
|
||||||
if (!authUser) throw new Error('Nicht autorisiert');
|
|
||||||
user = authUser;
|
|
||||||
} catch (authError: any) {
|
|
||||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
|
||||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
|
||||||
setIsOffline(true);
|
|
||||||
return handleSaveTasting(formData);
|
|
||||||
}
|
|
||||||
throw authError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
|
||||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
|
||||||
if (!bottleResult.success || !bottleResult.data) {
|
|
||||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottleId = bottleResult.data.id;
|
|
||||||
|
|
||||||
const tastingNote = {
|
|
||||||
...formData,
|
|
||||||
bottle_id: bottleId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tastingResult = await saveTasting(tastingNote);
|
|
||||||
if (!tastingResult.success) {
|
|
||||||
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTastingData(tastingNote);
|
|
||||||
setState('RESULT');
|
|
||||||
|
|
||||||
if (onBottleSaved) {
|
|
||||||
onBottleSaved(bottleId);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setState('ERROR');
|
setState('ERROR');
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New Effect: Watch for AI completion if we have a pending save
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanner.isAnalyzing && pendingTastingData && scanner.mergedResult) {
|
||||||
|
console.log('[ScanFlow] AI finished, triggering pending save');
|
||||||
|
performSave(pendingTastingData, scanner.mergedResult);
|
||||||
|
}
|
||||||
|
}, [scanner.isAnalyzing, pendingTastingData, scanner.mergedResult]);
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
@@ -479,7 +510,14 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
|
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
|
||||||
>
|
>
|
||||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||||
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">Speichere Tasting...</h2>
|
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">
|
||||||
|
{scanner.isAnalyzing ? 'Warte auf Analyse...' : 'Speichere Tasting...'}
|
||||||
|
</h2>
|
||||||
|
{scanner.isAnalyzing && (
|
||||||
|
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-[0.2em] animate-pulse">
|
||||||
|
KI verarbeitet Etikett-Details
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export async function processBulkScan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Trigger background analysis for all bottles (fire & forget)
|
// 4. Trigger background analysis for all bottles (fire & forget)
|
||||||
// This won't block the response
|
// Pass BOTH the bottle IDs and the original base64 images
|
||||||
triggerBackgroundAnalysis(bottleIds, user.id).catch(err => {
|
triggerBackgroundAnalysis(bottleIds, imageDataUrls, user.id).catch(err => {
|
||||||
console.error('Background analysis error:', err);
|
console.error('Background analysis error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,10 +166,17 @@ async function uploadImage(
|
|||||||
* Trigger background AI analysis for bottles.
|
* Trigger background AI analysis for bottles.
|
||||||
* This runs asynchronously and updates bottles with results.
|
* This runs asynchronously and updates bottles with results.
|
||||||
*/
|
*/
|
||||||
async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise<void> {
|
async function triggerBackgroundAnalysis(
|
||||||
|
bottleIds: string[],
|
||||||
|
imageDataUrls: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
for (const bottleId of bottleIds) {
|
for (let i = 0; i < bottleIds.length; i++) {
|
||||||
|
const bottleId = bottleIds[i];
|
||||||
|
const imageDataUrl = imageDataUrls[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update status to analyzing
|
// Update status to analyzing
|
||||||
await supabase
|
await supabase
|
||||||
@@ -177,20 +184,8 @@ async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): P
|
|||||||
.update({ processing_status: 'analyzing' })
|
.update({ processing_status: 'analyzing' })
|
||||||
.eq('id', bottleId);
|
.eq('id', bottleId);
|
||||||
|
|
||||||
// Get bottle image
|
// Call AI analysis with the base64 data directly
|
||||||
const { data: bottle } = await supabase
|
const analysisResult = await analyzeBottleImage(imageDataUrl);
|
||||||
.from('bottles')
|
|
||||||
.select('image_url')
|
|
||||||
.eq('id', bottleId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!bottle?.image_url) {
|
|
||||||
await markBottleError(supabase, bottleId, 'Kein Bild gefunden');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Gemini analysis
|
|
||||||
const analysisResult = await analyzeBottleImage(bottle.image_url);
|
|
||||||
|
|
||||||
if (analysisResult.success && analysisResult.data) {
|
if (analysisResult.success && analysisResult.data) {
|
||||||
// Update bottle with AI results
|
// Update bottle with AI results
|
||||||
@@ -237,33 +232,18 @@ async function markBottleError(
|
|||||||
* Analyze bottle image using configured AI provider
|
* Analyze bottle image using configured AI provider
|
||||||
* Uses OpenRouter by default, falls back to Gemini
|
* Uses OpenRouter by default, falls back to Gemini
|
||||||
*/
|
*/
|
||||||
async function analyzeBottleImage(imageUrl: string): Promise<{
|
async function analyzeBottleImage(dataUrl: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: {
|
data?: any;
|
||||||
name: string;
|
|
||||||
distillery?: string;
|
|
||||||
category?: string;
|
|
||||||
abv?: number;
|
|
||||||
age?: number;
|
|
||||||
is_whisky?: boolean;
|
|
||||||
confidence?: number;
|
|
||||||
};
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter');
|
const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter');
|
||||||
const provider = getAIProvider();
|
const provider = getAIProvider();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch image and convert to base64
|
// Extract base64 and mime type from data URL
|
||||||
const response = await fetch(imageUrl);
|
const base64Data = dataUrl.split(',')[1];
|
||||||
if (!response.ok) {
|
const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
|
||||||
return { success: false, error: 'Bild konnte nicht geladen werden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const buffer = await blob.arrayBuffer();
|
|
||||||
const base64 = Buffer.from(buffer).toString('base64');
|
|
||||||
const mimeType = blob.type || 'image/webp';
|
|
||||||
|
|
||||||
const prompt = `Analyze this whisky bottle image. Extract:
|
const prompt = `Analyze this whisky bottle image. Extract:
|
||||||
- name: Full product name
|
- name: Full product name
|
||||||
@@ -277,14 +257,13 @@ async function analyzeBottleImage(imageUrl: string): Promise<{
|
|||||||
Respond ONLY with valid JSON, no markdown.`;
|
Respond ONLY with valid JSON, no markdown.`;
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
if (provider === 'openrouter') {
|
||||||
// OpenRouter path
|
|
||||||
const client = getOpenRouterClient();
|
const client = getOpenRouterClient();
|
||||||
const openRouterResponse = await client.chat.completions.create({
|
const openRouterResponse = await client.chat.completions.create({
|
||||||
model: 'google/gemma-3-27b-it',
|
model: 'google/gemma-3-27b-it',
|
||||||
messages: [{
|
messages: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` } },
|
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
||||||
{ type: 'text', text: prompt },
|
{ type: 'text', text: prompt },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
@@ -296,13 +275,12 @@ Respond ONLY with valid JSON, no markdown.`;
|
|||||||
|
|
||||||
const content = openRouterResponse.choices[0]?.message?.content || '{}';
|
const content = openRouterResponse.choices[0]?.message?.content || '{}';
|
||||||
let jsonStr = content;
|
let jsonStr = content;
|
||||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
if (jsonMatch) jsonStr = jsonMatch[0].trim();
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
return { success: true, data: parsed };
|
return { success: true, data: parsed };
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Gemini path
|
|
||||||
const apiKey = process.env.GEMINI_API_KEY;
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' };
|
return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' };
|
||||||
@@ -317,7 +295,7 @@ Respond ONLY with valid JSON, no markdown.`;
|
|||||||
contents: [{
|
contents: [{
|
||||||
parts: [
|
parts: [
|
||||||
{ text: prompt },
|
{ text: prompt },
|
||||||
{ inline_data: { mime_type: mimeType, data: base64 } }
|
{ inline_data: { mime_type: mimeType, data: base64Data } }
|
||||||
]
|
]
|
||||||
}],
|
}],
|
||||||
generationConfig: { temperature: 0.1, maxOutputTokens: 500 }
|
generationConfig: { temperature: 0.1, maxOutputTokens: 500 }
|
||||||
@@ -344,7 +322,6 @@ Respond ONLY with valid JSON, no markdown.`;
|
|||||||
const parsed = JSON.parse(jsonMatch[0]);
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
return { success: true, data: parsed };
|
return { success: true, data: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[BulkScan] ${provider} analysis error:`, error);
|
console.error(`[BulkScan] ${provider} analysis error:`, error);
|
||||||
return { success: false, error: 'Analysefehler' };
|
return { success: false, error: 'Analysefehler' };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
|
|||||||
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
|
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
|
||||||
username TEXT UNIQUE,
|
username TEXT UNIQUE,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
|
deletion_requested_at TIMESTAMP WITH TIME ZONE,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ CREATE TABLE IF NOT EXISTS public.bottles (
|
|||||||
bottled_at TEXT,
|
bottled_at TEXT,
|
||||||
batch_info TEXT,
|
batch_info TEXT,
|
||||||
suggested_tags TEXT[],
|
suggested_tags TEXT[],
|
||||||
|
suggested_custom_tags TEXT[],
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
@@ -85,6 +87,7 @@ CREATE TABLE IF NOT EXISTS public.tastings (
|
|||||||
palate_notes TEXT,
|
palate_notes TEXT,
|
||||||
finish_notes TEXT,
|
finish_notes TEXT,
|
||||||
audio_transcript_url TEXT,
|
audio_transcript_url TEXT,
|
||||||
|
is_sample BOOLEAN DEFAULT false,
|
||||||
tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
@@ -325,6 +328,7 @@ ALTER TABLE public.tags ENABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
CREATE POLICY "profiles_select_policy" ON public.profiles FOR SELECT USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
|
CREATE POLICY "profiles_select_policy" ON public.profiles FOR SELECT USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
|
||||||
|
CREATE POLICY "profiles_insert_policy" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id);
|
||||||
CREATE POLICY "profiles_update_policy" ON public.profiles FOR UPDATE USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
|
CREATE POLICY "profiles_update_policy" ON public.profiles FOR UPDATE USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
|
||||||
|
|
||||||
CREATE POLICY "bottles_select_policy" ON public.bottles FOR SELECT USING (
|
CREATE POLICY "bottles_select_policy" ON public.bottles FOR SELECT USING (
|
||||||
@@ -385,3 +389,29 @@ INSERT INTO public.subscription_plans (name, display_name, monthly_credits, pric
|
|||||||
('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
|
('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
|
||||||
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
|
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 8. STORAGE POLICIES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Policies for 'bottles' bucket
|
||||||
|
-- These policies use a folder structure where the first part is the user's ID: auth.uid()
|
||||||
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
|
VALUES ('bottles', 'bottles', false)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE POLICY "Allow authenticated uploads" ON storage.objects
|
||||||
|
FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text);
|
||||||
|
|
||||||
|
CREATE POLICY "Allow authenticated selects" ON storage.objects
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (bucket_id = 'bottles');
|
||||||
|
|
||||||
|
CREATE POLICY "Allow authenticated updates" ON storage.objects
|
||||||
|
FOR UPDATE TO authenticated
|
||||||
|
USING (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text);
|
||||||
|
|
||||||
|
CREATE POLICY "Allow authenticated deletes" ON storage.objects
|
||||||
|
FOR DELETE TO authenticated
|
||||||
|
USING (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text);
|
||||||
|
|||||||
Reference in New Issue
Block a user