Files
Dramlog-Prod/src/components/CameraCapture.tsx

372 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink, ChevronRight } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { BottleMetadata } from '@/types/whisky';
import { savePendingBottle } from '@/lib/offline-db';
import { v4 as uuidv4 } from 'uuid';
import { findMatchingBottle } from '@/services/find-matching-bottle';
import { validateSession } from '@/services/validate-session';
import Link from 'next/link';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void;
onSaveComplete?: () => void;
}
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const supabase = createClientComponentClient();
const router = useRouter();
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const [validatedSessionId, setValidatedSessionId] = React.useState<string | null>(null);
React.useEffect(() => {
const checkSession = async () => {
if (sessionId) {
const isValid = await validateSession(sessionId);
setValidatedSessionId(isValid ? sessionId : null);
} else {
setValidatedSessionId(null);
}
};
checkSession();
}, [sessionId]);
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 [isQueued, setIsQueued] = useState(false);
const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null);
const [lastSavedId, setLastSavedId] = useState<string | null>(null);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsProcessing(true);
setError(null);
setAnalysisResult(null);
setIsQueued(false);
setMatchingBottle(null);
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
});
// heic2any can return an array if the file contains multiple images
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
type: 'image/jpeg'
});
}
const compressedBase64 = await compressImage(fileToProcess);
setPreviewUrl(compressedBase64);
if (onImageCaptured) {
onImageCaptured(compressedBase64);
}
// Check if Offline
if (!navigator.onLine) {
console.log('Offline detected. Queuing image...');
await savePendingBottle({
id: uuidv4(),
imageBase64: compressedBase64,
timestamp: Date.now(),
});
setIsQueued(true);
return;
}
const response = await analyzeBottle(compressedBase64);
if (response.success && response.data) {
setAnalysisResult(response.data);
// Duplicate Check
const match = await findMatchingBottle(response.data);
if (match) {
setMatchingBottle(match);
}
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 && response.data) {
setLastSavedId(response.data.id);
if (onSaveComplete) onSaveComplete();
} 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-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
<h2 className="text-xl md: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"
/>
{lastSavedId ? (
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<div className="flex items-center gap-2 text-green-600 font-bold justify-center p-2">
<CheckCircle2 size={24} className="text-green-500" />
Erfolgreich gespeichert!
</div>
<button
onClick={() => {
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
router.push(url);
}}
className="w-full py-4 px-6 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-zinc-800 dark:hover:bg-white transition-all shadow-xl"
>
Jetzt verkosten
<ChevronRight size={20} />
</button>
<button
onClick={() => {
setPreviewUrl(null);
setAnalysisResult(null);
setLastSavedId(null);
}}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
>
Später (Zurück zur Liste)
</button>
</div>
) : matchingBottle ? (
<Link
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
>
<ExternalLink size={20} />
Zum Whisky im Vault
</Link>
) : (
<button
onClick={isQueued ? () => setPreviewUrl(null) : (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...
</>
) : isQueued ? (
<>
<CheckCircle2 size={20} />
Nächste Flasche
</>
) : 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>
)}
{isQueued && (
<div className="flex items-center gap-2 text-purple-500 text-sm bg-purple-50 dark:bg-purple-900/10 p-4 rounded-xl w-full border border-purple-100 dark:border-purple-800/30 font-medium">
<Sparkles size={16} />
Offline! Foto wurde gemerkt wird automatisch analysiert, sobald du wieder Netz hast. 📡
</div>
)}
{matchingBottle && (
<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} />
Bereits im Vault!
</div>
<p className="text-xs text-blue-500/80">
Du hast diesen Whisky bereits in deiner Sammlung. Willst du direkt zur Flasche gehen?
</p>
<button
onClick={() => setMatchingBottle(null)}
className="text-[10px] text-zinc-400 font-black uppercase text-left hover:text-zinc-600"
>
Trotzdem als neue Flasche speichern
</button>
</div>
)}
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && (
<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-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
<Sparkles size={18} />
<span className="font-bold text-[10px] md: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>
);
}