feat: optimize scan flow with WebP compression and fix admin metrics visibility
This commit is contained in:
@@ -41,7 +41,7 @@ export default function BottlePage() {
|
||||
if (!bottleId) return null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24 space-y-6">
|
||||
<main className="min-h-screen bg-zinc-950 p-4 md:p-12 lg:p-24 space-y-6">
|
||||
<div className="max-w-4xl mx-auto flex justify-end">
|
||||
<OfflineIndicator />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,18 @@ body {
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Global Input Text Fix */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
@apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-none transition-all;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
@apply text-zinc-600;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
|
||||
@@ -12,7 +12,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||
import { useI18n } from "@/i18n/I18nContext";
|
||||
import { useSession } from "@/context/SessionContext";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||
|
||||
@@ -25,10 +25,15 @@ export default function Home() {
|
||||
const { t } = useI18n();
|
||||
const { activeSession } = useSession();
|
||||
const [isFlowOpen, setIsFlowOpen] = useState(false);
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
const handleImageSelected = (base64: string) => {
|
||||
setCapturedImage(base64);
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleImageSelected = (file: File) => {
|
||||
setCapturedFile(file);
|
||||
setIsFlowOpen(true);
|
||||
};
|
||||
|
||||
@@ -149,6 +154,14 @@ export default function Home() {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
if (!hasMounted) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
||||
<Loader2 className="animate-spin text-orange-600" size={40} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
||||
@@ -257,7 +270,7 @@ export default function Home() {
|
||||
<ScanAndTasteFlow
|
||||
isOpen={isFlowOpen}
|
||||
onClose={() => setIsFlowOpen(false)}
|
||||
base64Image={capturedImage}
|
||||
imageFile={capturedFile}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -188,29 +188,29 @@ export default function SessionDetailPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<Loader2 size={48} className="animate-spin text-amber-600" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
|
||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-50 dark:bg-black p-6">
|
||||
<h1 className="text-2xl font-bold">Session nicht gefunden</h1>
|
||||
<Link href="/" className="text-amber-600 font-bold">Zurück zum Start</Link>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-50">Session nicht gefunden</h1>
|
||||
<Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24">
|
||||
<main className="min-h-screen bg-zinc-950 p-4 md:p-12 lg:p-24">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Back Button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-zinc-400 hover:text-amber-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Alle Sessions
|
||||
@@ -219,7 +219,7 @@ export default function SessionDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<header className="bg-white dark:bg-zinc-900 rounded-3xl p-8 border border-zinc-200 dark:border-zinc-800 shadow-xl relative overflow-hidden group">
|
||||
<header className="bg-zinc-900 rounded-3xl p-8 border border-zinc-800 shadow-xl relative overflow-hidden group">
|
||||
{/* Visual Eyecatcher: Background Glow */}
|
||||
{tastings.length > 0 && tastings[0].bottles.image_url && (
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-20 dark:opacity-30 pointer-events-none">
|
||||
@@ -239,7 +239,7 @@ export default function SessionDetailPage() {
|
||||
{/* Visual Eyecatcher: Bottle Preview */}
|
||||
{tastings.length > 0 && tastings[0].bottles.image_url && (
|
||||
<div className="shrink-0 relative">
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-amber-500/20 shadow-2xl overflow-hidden relative group-hover:rotate-3 transition-transform duration-500">
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-zinc-800 border-2 border-orange-500/20 shadow-2xl overflow-hidden relative group-hover:rotate-3 transition-transform duration-500">
|
||||
<img
|
||||
src={tastings[0].bottles.image_url}
|
||||
alt={tastings[0].bottles.name}
|
||||
@@ -247,7 +247,7 @@ export default function SessionDetailPage() {
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 bg-amber-600 text-white text-[10px] font-black px-2 py-1 rounded-lg shadow-lg rotate-12">
|
||||
<div className="absolute -bottom-2 -right-2 bg-orange-600 text-white text-[10px] font-black px-2 py-1 rounded-lg shadow-lg rotate-12">
|
||||
LATEST
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +255,7 @@ export default function SessionDetailPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 font-black uppercase text-[10px] tracking-widest">
|
||||
<div className="flex items-center gap-2 text-orange-600 font-black uppercase text-[10px] tracking-widest">
|
||||
<Sparkles size={14} />
|
||||
Tasting Session
|
||||
</div>
|
||||
@@ -263,23 +263,23 @@ export default function SessionDetailPage() {
|
||||
<span className="bg-zinc-100 dark:bg-zinc-800 text-zinc-500 text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest border border-zinc-200 dark:border-zinc-700">Abgeschlossen</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white tracking-tighter">
|
||||
<h1 className="text-4xl md:text-5xl font-black text-zinc-50 tracking-tighter">
|
||||
{session.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-zinc-500 font-bold text-sm">
|
||||
<span className="flex items-center gap-1.5 bg-zinc-50 dark:bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<Calendar size={16} className="text-amber-600" />
|
||||
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
|
||||
<Calendar size={16} className="text-orange-600" />
|
||||
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
{participants.length > 0 && (
|
||||
<div className="flex items-center gap-2 bg-zinc-50 dark:bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<Users size={16} className="text-amber-600" />
|
||||
<div className="flex items-center gap-2 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
|
||||
<Users size={16} className="text-orange-600" />
|
||||
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
|
||||
</div>
|
||||
)}
|
||||
{tastings.length > 0 && (
|
||||
<span className="flex items-center gap-1.5 bg-zinc-50 dark:bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm transition-all animate-in fade-in slide-in-from-left-2">
|
||||
<GlassWater size={16} className="text-amber-600" />
|
||||
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm transition-all animate-in fade-in slide-in-from-left-2">
|
||||
<GlassWater size={16} className="text-orange-600" />
|
||||
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'}
|
||||
</span>
|
||||
)}
|
||||
@@ -292,7 +292,7 @@ export default function SessionDetailPage() {
|
||||
activeSession?.id !== session.id ? (
|
||||
<button
|
||||
onClick={() => setActiveSession({ id: session.id, name: session.name })}
|
||||
className="px-6 py-3 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-xl shadow-amber-600/20"
|
||||
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-xl shadow-orange-950/20"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
Starten
|
||||
@@ -301,7 +301,7 @@ export default function SessionDetailPage() {
|
||||
<button
|
||||
onClick={handleCloseSession}
|
||||
disabled={isClosing}
|
||||
className="px-6 py-3 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 border border-zinc-200 dark:border-zinc-800 hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white transition-all group"
|
||||
className="px-6 py-3 bg-zinc-100 text-zinc-900 rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 border border-zinc-800 hover:bg-red-600 hover:text-white transition-all group"
|
||||
>
|
||||
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={18} className="text-red-500 group-hover:text-white transition-colors" fill="currentColor" />}
|
||||
Beenden
|
||||
@@ -313,7 +313,7 @@ export default function SessionDetailPage() {
|
||||
onClick={handleDeleteSession}
|
||||
disabled={isDeleting}
|
||||
title="Session löschen"
|
||||
className="p-3 bg-red-50 dark:bg-red-900/10 text-red-600 dark:text-red-400 rounded-2xl hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white transition-all border border-red-100 dark:border-red-900/20 disabled:opacity-50"
|
||||
className="p-3 bg-red-900/10 text-red-400 rounded-2xl hover:bg-red-600 hover:text-white transition-all border border-red-900/20 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? <Loader2 size={20} className="animate-spin" /> : <Trash2 size={20} />}
|
||||
</button>
|
||||
@@ -324,9 +324,9 @@ export default function SessionDetailPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Sidebar: Participants */}
|
||||
<aside className="md:col-span-1 space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-400 mb-6 flex items-center gap-2">
|
||||
<Users size={16} className="text-amber-600" />
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 mb-6 flex items-center gap-2">
|
||||
<Users size={16} className="text-orange-600" />
|
||||
Teilnehmer
|
||||
</h3>
|
||||
|
||||
@@ -336,10 +336,10 @@ export default function SessionDetailPage() {
|
||||
) : (
|
||||
participants.map((p) => (
|
||||
<div key={p.buddy_id} className="flex items-center justify-between group">
|
||||
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">{p.buddies.name}</span>
|
||||
<span className="text-sm font-bold text-zinc-300">{p.buddies.name}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveParticipant(p.buddy_id)}
|
||||
className="text-zinc-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
className="text-zinc-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -348,14 +348,14 @@ export default function SessionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-100 dark:border-zinc-800 pt-6">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 block mb-3">Buddy hinzufügen</label>
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500 block mb-3">Buddy hinzufügen</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) handleAddParticipant(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold text-zinc-300 outline-none focus:ring-2 focus:ring-orange-500/50"
|
||||
>
|
||||
<option value="">Auswählen...</option>
|
||||
{allBuddies
|
||||
@@ -382,15 +382,15 @@ export default function SessionDetailPage() {
|
||||
|
||||
{/* Main Content: Bottle List */}
|
||||
<section className="md:col-span-2 space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-400 flex items-center gap-2">
|
||||
<GlassWater size={16} className="text-amber-600" />
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 flex items-center gap-2">
|
||||
<GlassWater size={16} className="text-orange-600" />
|
||||
Verkostete Flaschen
|
||||
</h3>
|
||||
<Link
|
||||
href={`/?session_id=${id}`} // Redirect to home with context
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-amber-600/20"
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Flasche hinzufügen
|
||||
|
||||
@@ -42,15 +42,15 @@ export default function AuthForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md p-8 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="w-full max-w-md p-8 bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-800">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-2xl flex items-center justify-center mb-4">
|
||||
{isLogin ? <LogIn className="text-amber-600" size={32} /> : <UserPlus className="text-amber-600" size={32} />}
|
||||
<div className="w-16 h-16 bg-orange-950/30 rounded-2xl flex items-center justify-center mb-4 border border-orange-900/20">
|
||||
{isLogin ? <LogIn className="text-orange-600" size={32} /> : <UserPlus className="text-orange-600" size={32} />}
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">
|
||||
<h2 className="text-3xl font-black text-white tracking-tight">
|
||||
{isLogin ? 'Willkommen zurück' : 'Vault erstellen'}
|
||||
</h2>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2 text-center text-sm">
|
||||
<p className="text-zinc-400 mt-2 text-center text-sm font-medium">
|
||||
{isLogin
|
||||
? 'Logge dich ein, um auf deine Sammlung zuzugreifen.'
|
||||
: 'Starte heute mit deinem digitalen Whisky-Vault.'}
|
||||
@@ -59,7 +59,7 @@ export default function AuthForm() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 ml-1">E-Mail</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">E-Mail</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<input
|
||||
@@ -68,13 +68,13 @@ export default function AuthForm() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none transition-all dark:text-white"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 ml-1">Passwort</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Passwort</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<input
|
||||
@@ -83,20 +83,20 @@ export default function AuthForm() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none transition-all dark:text-white"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-900/50">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/10 text-red-500 text-xs rounded-lg border border-red-900/20">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 text-sm rounded-lg border border-green-100 dark:border-green-900/50">
|
||||
<div className="p-3 bg-green-900/10 text-green-500 text-xs rounded-lg border border-green-900/20">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
@@ -104,7 +104,7 @@ export default function AuthForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl shadow-lg shadow-amber-600/20 transition-all active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
className="w-full py-4 bg-orange-600 hover:bg-orange-700 text-white font-black uppercase tracking-widest text-xs rounded-xl shadow-lg shadow-orange-950/40 transition-all active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={20} /> : (isLogin ? 'Einloggen' : 'Konto erstellen')}
|
||||
</button>
|
||||
@@ -112,8 +112,9 @@ export default function AuthForm() {
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-sm font-medium text-amber-600 hover:text-amber-700 transition-colors"
|
||||
className="text-xs font-black uppercase tracking-widest text-orange-600 hover:text-orange-500 transition-colors"
|
||||
>
|
||||
{isLogin ? 'Noch kein Konto? Registrieren' : 'Bereits ein Konto? Einloggen'}
|
||||
</button>
|
||||
|
||||
@@ -33,11 +33,11 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
||||
if (!bottle && !loading) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<div className="w-20 h-20 bg-zinc-100 dark:bg-zinc-900 rounded-full flex items-center justify-center text-zinc-400">
|
||||
<div className="w-20 h-20 bg-zinc-900 rounded-full flex items-center justify-center text-zinc-500">
|
||||
<WifiOff size={40} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-2">Flasche nicht verfügbar</h2>
|
||||
<h2 className="text-xl font-black text-zinc-50 mb-2">Flasche nicht verfügbar</h2>
|
||||
<p className="text-zinc-500 text-sm max-w-xs mx-auto">
|
||||
Inhalte konnten nicht geladen werden. Bitte stelle eine Internetverbindung her, um diese Flasche zum ersten Mal zu laden.
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface BottomNavigationProps {
|
||||
onShelf?: () => void;
|
||||
onSearch?: () => void;
|
||||
onProfile?: () => void;
|
||||
onScan: (base64: string) => void;
|
||||
onScan: (file: File) => void;
|
||||
}
|
||||
|
||||
export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan }: BottomNavigationProps) => {
|
||||
@@ -22,11 +22,7 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
onScan(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
onScan(file);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
|
||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User, Clock } from 'lucide-react';
|
||||
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
@@ -19,6 +19,7 @@ import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { shortenCategory } from '@/lib/format';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { processImageForAI } from '@/utils/image-processing';
|
||||
interface CameraCaptureProps {
|
||||
onImageCaptured?: (base64Image: string) => void;
|
||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||
@@ -67,16 +68,32 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
||||
|
||||
// Performance Tracking (Admin only)
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
compression: number;
|
||||
ai: number;
|
||||
prep: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
setIsAdmin(!!data);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('[CameraCapture] Admin check error:', error);
|
||||
}
|
||||
console.log('[CameraCapture] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CameraCapture] checkAdmin failed:', err);
|
||||
}
|
||||
};
|
||||
checkAdmin();
|
||||
@@ -91,6 +108,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
setAnalysisResult(null);
|
||||
setIsQueued(false);
|
||||
setMatchingBottle(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
let fileToProcess = file;
|
||||
@@ -115,7 +133,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
setOriginalFile(fileToProcess);
|
||||
|
||||
const compressedBase64 = await compressImage(fileToProcess);
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(fileToProcess);
|
||||
const endComp = performance.now();
|
||||
|
||||
const compressedBase64 = processed.base64;
|
||||
setPreviewUrl(compressedBase64);
|
||||
|
||||
if (onImageCaptured) {
|
||||
@@ -136,8 +158,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
return;
|
||||
}
|
||||
|
||||
const startAi = performance.now();
|
||||
const response = await magicScan(compressedBase64, aiProvider, locale);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (response.success && response.data) {
|
||||
setAnalysisResult(response.data);
|
||||
|
||||
@@ -158,6 +183,16 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(response.data);
|
||||
}
|
||||
|
||||
const endPrep = performance.now();
|
||||
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
compression: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If scan fails but it looks like a network issue, offer to queue
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
@@ -205,21 +240,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
let imageUrl = undefined;
|
||||
if (originalFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', originalFile);
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.url) {
|
||||
imageUrl = uploadData.url;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl);
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
@@ -247,21 +269,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
let imageUrl = undefined;
|
||||
if (originalFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', originalFile);
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.url) {
|
||||
imageUrl = uploadData.url;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl);
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setLastSavedId(response.data.id);
|
||||
@@ -304,42 +313,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
}
|
||||
};
|
||||
|
||||
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 = 1200;
|
||||
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.9);
|
||||
resolve(base64);
|
||||
};
|
||||
img.onerror = reject;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -350,22 +323,22 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
};
|
||||
|
||||
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">
|
||||
<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-zinc-900 rounded-3xl shadow-2xl border border-zinc-800 transition-all hover:shadow-orange-950/20">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-xl border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
||||
<button
|
||||
onClick={() => setAiProvider('gemini')}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Gemini
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider('mistral')}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Mistral 3 🇪🇺
|
||||
</button>
|
||||
@@ -373,10 +346,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
</div>
|
||||
{activeSession && (
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-amber-600 animate-in slide-in-from-left-2 duration-500">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-orange-600 animate-in slide-in-from-left-2 duration-500">
|
||||
<div className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-500"></span>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-orange-500"></span>
|
||||
</div>
|
||||
{activeSession.name}
|
||||
</div>
|
||||
@@ -384,26 +357,48 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
</div>
|
||||
|
||||
<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"
|
||||
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-800 overflow-hidden flex items-center justify-center bg-zinc-900/50 hover:border-orange-500/50 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">
|
||||
<div className="flex flex-col items-center gap-2 text-zinc-600 group-hover:text-orange-500 transition-colors">
|
||||
<Camera size={48} strokeWidth={1.5} />
|
||||
<span className="text-sm font-medium">{t('camera.scanBottle')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-2 left-2 p-2 bg-black/80 backdrop-blur-md rounded-lg border border-orange-500/30 text-[9px] font-mono text-white/90 z-10 pointer-events-none">
|
||||
<div className="font-bold text-orange-500 mb-1 uppercase tracking-tighter">Perf Metrics</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Comp:</span>
|
||||
<span className="text-orange-400">{perfMetrics.compression.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>AI:</span>
|
||||
<span className="text-orange-400">{perfMetrics.ai.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Prep:</span>
|
||||
<span className="text-orange-400">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="pt-1 mt-1 border-t border-white/10 flex justify-between gap-4 font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-white">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-4 text-white p-6 text-center animate-in fade-in duration-300">
|
||||
<div className="relative">
|
||||
<Loader2 size={48} className="animate-spin text-amber-500" />
|
||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||
<Wand2 size={20} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-black uppercase tracking-[0.2em] text-[10px] text-amber-500">Magic Analysis</p>
|
||||
<p className="font-black uppercase tracking-[0.2em] text-[10px] text-orange-500">Magic Analysis</p>
|
||||
<p className="text-sm font-bold">
|
||||
{!navigator.onLine ? 'Offline: Speichere lokal...' : 'Analysiere Flasche...'}
|
||||
</p>
|
||||
@@ -453,7 +448,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{!wbDiscovery && !isDiscovering && (
|
||||
<button
|
||||
onClick={handleDiscoverWb}
|
||||
className="w-full py-3 px-6 bg-amber-50 dark:bg-amber-900/20 text-amber-600 rounded-xl font-bold flex items-center justify-center gap-2 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 transition-all text-sm"
|
||||
className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm"
|
||||
>
|
||||
<Search size={16} />
|
||||
{t('camera.whiskybaseSearch')}
|
||||
@@ -468,17 +463,17 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
|
||||
{wbDiscovery && (
|
||||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-amber-600">
|
||||
<div className="p-4 bg-zinc-950 border border-orange-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-orange-600">
|
||||
<Sparkles size={12} /> {t('camera.wbMatchFound')}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug">
|
||||
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">
|
||||
{wbDiscovery.title}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleLinkWb}
|
||||
className="flex-1 py-2.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
|
||||
className="flex-1 py-2.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
{t('common.link')}
|
||||
</button>
|
||||
@@ -486,7 +481,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
href={wbDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-2.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 transition-colors flex items-center justify-center gap-1"
|
||||
className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<ExternalLink size={12} /> {t('common.check')}
|
||||
</a>
|
||||
@@ -500,7 +495,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
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"
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.later')}
|
||||
</button>
|
||||
@@ -509,14 +504,14 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
<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"
|
||||
className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('camera.toVault')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMatchingBottle(null)}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.saveAnyway')}
|
||||
</button>
|
||||
@@ -538,7 +533,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing || isSaving}
|
||||
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 shadow-black/10' : 'bg-amber-600 hover:bg-amber-700 text-white shadow-amber-600/20'}`}
|
||||
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-100 text-zinc-900 shadow-black/10' : 'bg-orange-600 hover:bg-orange-700 text-white shadow-orange-950/40'}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
@@ -553,7 +548,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
) : previewUrl && analysisResult ? (
|
||||
validatedSessionId ? (
|
||||
<>
|
||||
<Droplets size={20} className="text-amber-500" />
|
||||
<Droplets size={20} className="text-orange-500" />
|
||||
{t('camera.quickTasting')}
|
||||
</>
|
||||
) : (
|
||||
@@ -578,7 +573,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{!previewUrl && !isProcessing && (
|
||||
<button
|
||||
onClick={triggerGallery}
|
||||
className="w-full py-3 px-6 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-200 transition-all text-sm"
|
||||
className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm"
|
||||
>
|
||||
<Upload size={18} />
|
||||
{t('camera.uploadGallery')}
|
||||
@@ -627,20 +622,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{/* Analysis Results Display */}
|
||||
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm bg-green-900/10 p-3 rounded-lg w-full border border-green-900/30">
|
||||
<CheckCircle2 size={16} />
|
||||
{t('camera.analysisSuccess')}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="p-3 md:p-4 bg-zinc-950 rounded-2xl border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600">
|
||||
<Sparkles size={18} />
|
||||
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.name || '-'}</span>
|
||||
<span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
||||
@@ -675,7 +670,33 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.batch_info}</span>
|
||||
<span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="pt-4 mt-2 border-t border-zinc-900/50 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1">
|
||||
<Clock size={10} /> Performance Data
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Comp:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">AI:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.ai.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Prep:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Total:</span>
|
||||
<span className="text-orange-600 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,13 +107,13 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit border border-zinc-700"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
{t('bottle.editDetails')}
|
||||
</button>
|
||||
{bottle.purchase_price && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-900/10 text-green-400 rounded-xl text-sm font-bold border border-green-900/30 w-fit">
|
||||
<CircleDollarSign size={16} />
|
||||
{t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -152,7 +152,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.distillery}
|
||||
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -161,7 +161,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -172,7 +172,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
step="0.1"
|
||||
value={formData.abv}
|
||||
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -181,7 +181,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
type="text"
|
||||
value={formData.whiskybase_id}
|
||||
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
{discoveryResult && (
|
||||
<div className="mt-2 p-3 bg-zinc-950 border border-orange-500/20 rounded-xl animate-in fade-in slide-in-from-top-2">
|
||||
@@ -220,7 +220,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
href={discoveryResult.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1"
|
||||
className="px-3 py-1.5 bg-zinc-800 text-zinc-400 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center gap-1 border border-zinc-700"
|
||||
>
|
||||
<ExternalLink size={10} /> {t('common.check')}
|
||||
</a>
|
||||
@@ -247,7 +247,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. 2010"
|
||||
value={formData.distilled_at}
|
||||
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. 2022"
|
||||
value={formData.bottled_at}
|
||||
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
||||
placeholder="z.B. Batch 12 oder L-Code"
|
||||
value={formData.batch_info}
|
||||
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50"
|
||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Loader2, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { X, Loader2, Sparkles, AlertCircle, Clock } from 'lucide-react';
|
||||
import TastingEditor from './TastingEditor';
|
||||
import SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
@@ -13,52 +13,101 @@ import { saveTasting } from '@/services/save-tasting';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||
|
||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||
|
||||
interface ScanAndTasteFlowProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
base64Image: string | null;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanAndTasteFlowProps) {
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAndTasteFlowProps) {
|
||||
const [state, setState] = useState<FlowState>('IDLE');
|
||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||
const { activeSession } = useSession();
|
||||
const [processedImage, setProcessedImage] = useState<ProcessedImage | null>(null);
|
||||
const [tastingData, setTastingData] = useState<any>(null);
|
||||
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { locale } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [perfMetrics, setPerfMetrics] = useState<{ comp: number; ai: number; prep: number } | null>(null);
|
||||
|
||||
// Admin Check
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) console.error('[ScanFlow] Admin check error:', error);
|
||||
console.log('[ScanFlow] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ScanFlow] checkAdmin failed:', err);
|
||||
}
|
||||
};
|
||||
checkAdmin();
|
||||
}, [supabase]);
|
||||
|
||||
// Trigger scan when open and image provided
|
||||
useEffect(() => {
|
||||
if (isOpen && base64Image) {
|
||||
if (isOpen && imageFile) {
|
||||
console.log('[ScanFlow] Starting handleScan...');
|
||||
handleScan(base64Image);
|
||||
handleScan(imageFile);
|
||||
} else if (!isOpen) {
|
||||
setState('IDLE');
|
||||
setTastingData(null);
|
||||
setBottleMetadata(null);
|
||||
setProcessedImage(null);
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [isOpen, base64Image]);
|
||||
}, [isOpen, imageFile]);
|
||||
|
||||
const handleScan = async (image: string) => {
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
setError(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
const cleanBase64 = image.split(',')[1] || image;
|
||||
console.log('[ScanFlow] Calling magicScan service...');
|
||||
const result = await magicScan(cleanBase64, 'gemini', locale);
|
||||
console.log('[ScanFlow] Starting image processing...');
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(file);
|
||||
const endComp = performance.now();
|
||||
setProcessedImage(processed);
|
||||
|
||||
const cleanBase64 = processed.base64.split(',')[1] || processed.base64;
|
||||
console.log('[ScanFlow] Calling magicScan service with compressed images (WebP)...');
|
||||
|
||||
const startAi = performance.now();
|
||||
const result = await magicScan(cleanBase64, 'gemini', locale);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (result.success && result.data) {
|
||||
console.log('[ScanFlow] magicScan success');
|
||||
setBottleMetadata(result.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
});
|
||||
}
|
||||
|
||||
setState('EDITOR');
|
||||
} else {
|
||||
console.error('[ScanFlow] magicScan failure:', result.error);
|
||||
@@ -72,7 +121,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
};
|
||||
|
||||
const handleSaveTasting = async (formData: any) => {
|
||||
if (!bottleMetadata || !base64Image) return;
|
||||
if (!bottleMetadata || !processedImage) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
@@ -81,8 +130,8 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
// 1. Save Bottle
|
||||
const bottleResult = await saveBottle(bottleMetadata, base64Image, user.id);
|
||||
// 1. Save Bottle - Use compressed base64 for storage as well
|
||||
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
@@ -149,7 +198,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
If we are IDLE but have an image, we are essentially SCANNING (or about to be).
|
||||
If we have no image, we shouldn't really be here, but show error just in case.
|
||||
*/}
|
||||
{(state === 'SCANNING' || (state === 'IDLE' && base64Image)) && (
|
||||
{(state === 'SCANNING' || (state === 'IDLE' && imageFile)) && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
@@ -173,6 +222,25 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="mt-8 p-4 bg-black/40 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[10px] font-mono text-zinc-400 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">Comp</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.comp.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">AI</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.ai.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-1 uppercase tracking-widest text-[8px]">Prep</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.prep.toFixed(0)}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -210,12 +278,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
>
|
||||
<TastingEditor
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={base64Image}
|
||||
image={processedImage?.base64 || null}
|
||||
onSave={handleSaveTasting}
|
||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||
activeSessionName={activeSession?.name}
|
||||
activeSessionId={activeSession?.id}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 z-50 p-2 bg-black/60 backdrop-blur-md rounded-lg border border-orange-500/30 text-[9px] font-mono text-white/90 pointer-events-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={10} className="text-orange-500" />
|
||||
<span>Comp: {perfMetrics.comp.toFixed(0)}ms</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>AI: {perfMetrics.ai.toFixed(0)}ms</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>Prep: {perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -246,7 +326,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
|
||||
balance: tastingData.balance || 85,
|
||||
}}
|
||||
bottleName={bottleMetadata.name || 'Unknown Whisky'}
|
||||
image={base64Image}
|
||||
image={processedImage?.base64 || null}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -206,32 +206,32 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{activeSession && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="bg-amber-600 text-white p-2 rounded-xl">
|
||||
<div className="p-3 bg-orange-950/20 border border-orange-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="bg-orange-600 text-white p-2 rounded-xl">
|
||||
<Sparkles size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-amber-700 dark:text-amber-400">Recording for Session</p>
|
||||
<p className="text-xs font-bold text-amber-900 dark:text-amber-200 truncate">{activeSession.name}</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-orange-500">Recording for Session</p>
|
||||
<p className="text-xs font-bold text-orange-200 truncate">{activeSession.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaletteWarning && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-2xl flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertTriangle size={20} className="text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-2xl flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertTriangle size={20} className="text-orange-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-amber-600">Palette-Checker Warnung</p>
|
||||
<p className="text-xs font-bold text-amber-900 dark:text-amber-200">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-orange-600">Palette-Checker Warnung</p>
|
||||
<p className="text-xs font-bold text-orange-200">
|
||||
Dein letzter Dram war "{lastDramInSession?.name}".
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-800/80 dark:text-amber-400/80 leading-relaxed font-medium">
|
||||
<p className="text-[10px] text-orange-400/80 leading-relaxed font-medium">
|
||||
Da er sehr torfig war und erst vor Kurzem verkostet wurde, könnten deine Geschmacksnerven noch beeinträchtigt sein. Trink am besten etwas Wasser!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaletteWarning(false)}
|
||||
className="text-[9px] font-black uppercase text-amber-600 underline"
|
||||
className="text-[9px] font-black uppercase text-orange-600 underline"
|
||||
>
|
||||
Ignorieren
|
||||
</button>
|
||||
@@ -242,10 +242,10 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Star size={14} className="text-amber-500 fill-amber-500" />
|
||||
<Star size={14} className="text-orange-500 fill-orange-500" />
|
||||
{t('tasting.rating')}
|
||||
</label>
|
||||
<span className="text-2xl font-black text-amber-600 tracking-tighter">{rating}<span className="text-zinc-400 text-sm ml-0.5 font-bold">/100</span></span>
|
||||
<span className="text-2xl font-black text-orange-600 tracking-tighter">{rating}<span className="text-zinc-500 text-sm ml-0.5 font-bold">/100</span></span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -253,7 +253,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
max="100"
|
||||
value={rating}
|
||||
onChange={(e) => setRating(parseInt(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-200 dark:bg-zinc-800 rounded-full appearance-none cursor-pointer accent-amber-600 hover:accent-amber-500 transition-all"
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 hover:accent-orange-500 transition-all"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-zinc-400 font-black uppercase tracking-widest px-1">
|
||||
<span>Swill</span>
|
||||
@@ -264,13 +264,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('tasting.overall')}</label>
|
||||
<div className="grid grid-cols-2 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
|
||||
<div className="grid grid-cols-2 gap-2 p-1 bg-zinc-950 rounded-2xl border border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(false)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${!isSample
|
||||
? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm ring-1 ring-black/5'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Bottle
|
||||
@@ -279,8 +279,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
type="button"
|
||||
onClick={() => setIsSample(true)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${isSample
|
||||
? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm ring-1 ring-black/5'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Sample
|
||||
@@ -290,13 +290,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Nose Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Wind size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.nose')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Aroma & Eindruck</p>
|
||||
@@ -318,20 +318,20 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setNose(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palate Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Utensils size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.palate')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Geschmack & Textur</p>
|
||||
@@ -353,20 +353,20 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setPalate(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish Section */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Droplets size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.finish')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Abgang & Nachhall</p>
|
||||
@@ -401,7 +401,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
onChange={(e) => setFinish(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +411,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
{buddies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Users size={14} className="text-amber-500" />
|
||||
<Users size={14} className="text-orange-500" />
|
||||
{t('tasting.participants')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -421,8 +421,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
type="button"
|
||||
onClick={() => toggleBuddy(buddy.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all flex items-center gap-1.5 border shadow-sm ${selectedBuddyIds.includes(buddy.id)
|
||||
? 'bg-amber-600 border-amber-600 text-white shadow-amber-600/20'
|
||||
: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-amber-500/50'
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-orange-600/20'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:border-orange-500/50'
|
||||
}`}
|
||||
>
|
||||
{selectedBuddyIds.includes(buddy.id) && <Check size={10} />}
|
||||
@@ -442,7 +442,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10 dark:shadow-amber-900/10"
|
||||
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : (
|
||||
<>
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function analyzeBottleMistral(base64Image: string, tags?: string[],
|
||||
}
|
||||
|
||||
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
|
||||
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
||||
const dataUrl = `data:image/webp;base64,${base64Data}`;
|
||||
|
||||
const prompt = getSystemPrompt(tags ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function analyzeBottle(base64Image: string, tags?: string[], locale
|
||||
{
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType: 'image/jpeg',
|
||||
mimeType: 'image/webp',
|
||||
},
|
||||
},
|
||||
{ text: instruction },
|
||||
|
||||
@@ -26,12 +26,13 @@ export async function saveBottle(
|
||||
if (!finalImageUrl && base64Image) {
|
||||
const base64Data = base64Image.split(',')[1] || base64Image;
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const fileName = `${userId}/${uuidv4()}.jpg`;
|
||||
const isWebp = base64Image.startsWith('data:image/webp');
|
||||
const fileName = `${userId}/${uuidv4()}.${isWebp ? 'webp' : 'jpg'}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('bottles')
|
||||
.upload(fileName, buffer, {
|
||||
contentType: 'image/jpeg',
|
||||
contentType: isWebp ? 'image/webp' : 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
|
||||
80
src/utils/image-processing.ts
Normal file
80
src/utils/image-processing.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import imageCompression from 'browser-image-compression';
|
||||
|
||||
/**
|
||||
* Interface for the processed image result
|
||||
*/
|
||||
export interface ProcessedImage {
|
||||
file: File; // The compressed WebP file (ready for Supabase storage)
|
||||
base64: string; // The Base64 string (ready for LLM API calls)
|
||||
originalFile: File; // Pass through the original file
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a File or Blob object to a Base64 string.
|
||||
*
|
||||
* @param file - The file or blob to convert
|
||||
* @returns A promise that resolves to the Base64 string
|
||||
*/
|
||||
export function fileToBase64(file: File | Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to convert file to Base64 string'));
|
||||
}
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an image file for AI analysis and storage.
|
||||
*
|
||||
* Logic:
|
||||
* 1. Resize to max 1024x1024 (maintains aspect ratio)
|
||||
* 2. Convert to WebP format
|
||||
* 3. Limit file size to approx 0.4MB
|
||||
* 4. Uses WebWorker to prevent UI freezing
|
||||
*
|
||||
* @param file - The raw File object from an HTML input
|
||||
* @returns A promise that resolves to a ProcessedImage object
|
||||
*/
|
||||
export async function processImageForAI(file: File): Promise<ProcessedImage> {
|
||||
const options = {
|
||||
maxSizeMB: 0.4,
|
||||
maxWidthOrHeight: 1024,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/webp'
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[processImageForAI] Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// Compress the image
|
||||
const compressedBlob = await imageCompression(file, options);
|
||||
|
||||
// Create a new File object from the compressed Blob with .webp extension
|
||||
const compressedFile = new File(
|
||||
[compressedBlob],
|
||||
file.name.replace(/\.[^/.]+$/, "") + ".webp",
|
||||
{ type: 'image/webp' }
|
||||
);
|
||||
|
||||
console.log(`[processImageForAI] Compressed size: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// Convert to Base64 for AI API calls
|
||||
const base64 = await fileToBase64(compressedFile);
|
||||
|
||||
return {
|
||||
file: compressedFile,
|
||||
base64,
|
||||
originalFile: file
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[processImageForAI] Error processing image:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user