372 lines
17 KiB
TypeScript
372 lines
17 KiB
TypeScript
'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>
|
||
);
|
||
}
|