init
This commit is contained in:
236
src/components/CameraCapture.tsx
Normal file
236
src/components/CameraCapture.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user