This commit is contained in:
2025-12-17 23:12:53 +01:00
commit 5807d949ef
323 changed files with 34158 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
'use client';
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { BottleMetadata } from '@/types/whisky';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void;
onSaveComplete?: () => void;
}
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const supabase = createClientComponentClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [analysisResult, setAnalysisResult] = useState<BottleMetadata | null>(null);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsProcessing(true);
setError(null);
setAnalysisResult(null);
try {
const compressedBase64 = await compressImage(file);
setPreviewUrl(compressedBase64);
if (onImageCaptured) {
onImageCaptured(compressedBase64);
}
const response = await analyzeBottle(compressedBase64);
if (response.success && response.data) {
setAnalysisResult(response.data);
if (onAnalysisComplete) {
onAnalysisComplete(response.data);
}
} else {
setError(response.error || 'Analyse fehlgeschlagen.');
}
} catch (err) {
console.error('Processing failed:', err);
setError('Verarbeitung fehlgeschlagen. Bitte erneut versuchen.');
} finally {
setIsProcessing(false);
}
};
const handleSave = async () => {
if (!analysisResult || !previewUrl) return;
setIsSaving(true);
setError(null);
try {
// Get current user (simple check for now, can be improved with Auth)
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Bitte melde dich an, um Flaschen zu speichern.');
}
const response = await saveBottle(analysisResult, previewUrl, user.id);
if (response.success) {
setPreviewUrl(null);
setAnalysisResult(null);
if (onSaveComplete) onSaveComplete();
// Optionale Erfolgsmeldung oder Redirect
} else {
setError(response.error || 'Speichern fehlgeschlagen.');
}
} catch (err) {
console.error('Save failed:', err);
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.');
} finally {
setIsSaving(false);
}
};
const compressImage = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 1024;
let width = img.width;
let height = img.height;
if (width > MAX_WIDTH) {
height = (height * MAX_WIDTH) / width;
width = MAX_WIDTH;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas context not available'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
const base64 = canvas.toDataURL('image/jpeg', 0.8);
resolve(base64);
};
img.onerror = reject;
};
reader.onerror = reject;
});
};
const triggerUpload = () => {
fileInputRef.current?.click();
};
return (
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
<h2 className="text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">Magic Shot</h2>
<div
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
onClick={triggerUpload}
>
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2 text-zinc-400 group-hover:text-amber-500 transition-colors">
<Camera size={48} strokeWidth={1.5} />
<span className="text-sm font-medium">Flasche scannen</span>
</div>
)}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
)}
</div>
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleCapture}
className="hidden"
/>
<button
onClick={previewUrl && analysisResult ? handleSave : triggerUpload}
disabled={isProcessing || isSaving}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20 disabled:opacity-50"
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Wird gespeichert...
</>
) : previewUrl && analysisResult ? (
<>
<CheckCircle2 size={20} />
Im Vault speichern
</>
) : previewUrl ? (
<>
<Upload size={20} />
Neu aufnehmen
</>
) : (
<>
<Camera size={20} />
Kamera öffnen
</>
)}
</button>
{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}
</div>
)}
{previewUrl && !isProcessing && !error && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full">
<CheckCircle2 size={16} />
Bild erfolgreich analysiert
</div>
{analysisResult && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 mb-3 text-amber-600 dark:text-amber-500">
<Sparkles size={18} />
<span className="font-bold text-sm uppercase tracking-wider">Ergebnisse</span>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Name:</span>
<span className="font-semibold">{analysisResult.name || '-'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Distille:</span>
<span className="font-semibold">{analysisResult.distillery || '-'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Kategorie:</span>
<span className="font-semibold">{analysisResult.category || '-'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">ABV:</span>
<span className="font-semibold">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}