feat: optimize scan flow with WebP compression and fix admin metrics visibility

This commit is contained in:
2025-12-22 10:15:29 +01:00
parent f0588418c8
commit 5e35710b67
19 changed files with 477 additions and 332 deletions

123
.aiideas
View File

@@ -1,103 +1,28 @@
Rolle: Du bist ein UI - Designer mit Fokus auf "Modern Minimalist" Design(Stilrichtung: Linear, Teenage Engineering, Vercel).Ziel: Redesign der Whisky - App "DramLog".Wir verabschieden uns vom klassischen "Luxus-Look"(Gold / Serifen) und nutzen einen "Industrial Dark" Stil. Act as a Senior TypeScript/Next.js Developer.
Design Rules & Tokens: I need a robust client-side image processing utility (an "Image Agent") to optimize user uploads before sending them to an LLM or Supabase.
Color Palette(Tailwind): **Task:**
Create a utility file `src/utils/image-processing.ts`.
This file should export a function `processImageForAI` that uses the library `browser-image-compression`.
Bg - App: bg - zinc - 950(Ein sehr dunkles, warmes Grau, kein hartes Schwarz). **Requirements:**
1. **Input:** The function takes a raw `File` object (from an HTML input).
2. **Processing Logic:**
- Resize the image to a maximum of **1024x1024** pixels (maintain aspect ratio).
- Convert the image to **WebP** format.
- Limit the file size to approx **0.4MB**.
- Enable `useWebWorker: true` to prevent UI freezing.
3. **Output:** The function must return a Promise that resolves to an object with this interface:
```typescript
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
}
```
4. **Helper:** Include a helper function to convert the resulting Blob/File to a Base64 string correctly.
5. **Edge Cases:** Handle errors gracefully (try/catch) and ensure the returned `file` has the correct `.webp` extension and mime type.
Bg - Card: bg - zinc - 900(Deutlich abgesetzt vom Hintergrund). **Step 1:** Give me the `npm install` command to add the necessary library.
**Step 2:** Write the complete `src/utils/image-processing.ts` code with proper JSDoc comments.
Text - Primary: text - zinc - 50(Fast Weiß, hoher Kontrast).
Text - Secondary: text - zinc - 400(Mittelgrau für Labels).
Accent: text - orange - 500(Ein sattes, mattes Orange für Highlights) und bg - orange - 600 für Primary Buttons.Keine Verläufe / Gradients, sondern flache("flat") Farben.
Typography:
Nutze Inter oder DM Sans für alles.
Headlines: font - bold tracking - tight(Enger Buchstabenabstand, wirkt kompakt und modern).
Labels: uppercase text - xs tracking - widest font - semibold(Technischer Look).
Component: Whisky Card(Clean Split):
Kein Text mehr über dem Bild!
Top: Bild(Aspect Ratio 4: 3 oder 16: 9), rounded - t - xl.
Bottom: Info - Block(p - 4 bg - zinc - 900 rounded - b - xl).
Inhalt: Whisky Name(Bold, White), darunter Destillerie(Orange, Small).
Tags: Kleine "Pills" mit bg - zinc - 800 text - zinc - 300.
Component: Dashboard Stats:
Minimalistisch.Nur die Zahl(riesig, z.B.text - 4xl font - bold text - white) und darunter das Label(text - zinc - 500).
Keine Boxen, keine Rahmen. "Data Ink Ratio" optimieren.
Component: Floating Navigation(The Capsule):
Statt einer durchgehenden Leiste unten, nutze eine "Floating Capsule".
Eine abgerundete "Insel"(rounded - full) die ca. 20px über dem unteren Bildschirmrand schwebt.
Breite: ca. 90 % des Screens oder max - w - md.
Farbe: bg - zinc - 800 / 90 backdrop - blur - md border border - zinc - 700.
Scan Button: In der Mitte der Kapsel, Kreis in bg - orange - 600(Flat, kein Schatten), weißes Icon.
Code - Snippet für die "Industrial Capsule Navigation":
JavaScript
import { Home, Grid, Scan, User } from 'lucide-react';
export const NavigationCapsule = () => {
return (
<div className= "fixed bottom-6 left-1/2 -translate-x-1/2 w-[90%] max-w-sm z-50" >
<div className="flex items-center justify-between px-2 py-2 bg-zinc-900/90 backdrop-blur-lg border border-zinc-800 rounded-full shadow-2xl" >
{/* Left Item */ }
< button className = "p-3 text-zinc-400 hover:text-white transition-colors" >
<Home size={ 22 } strokeWidth = { 2.5} />
</button>
{/* Left Item */ }
<button className="p-3 text-zinc-400 hover:text-white transition-colors" >
<Grid size={ 22 } strokeWidth = { 2.5} />
</button>
{/* PRIMARY ACTION - The "Industrial Button" */ }
<button className="flex items-center justify-center w-14 h-14 rounded-full bg-orange-600 text-white hover:bg-orange-500 active:scale-95 transition-all mx-2" >
<Scan size={ 26 } strokeWidth = { 2.5} />
</button>
{/* Right Item */ }
<button className="p-3 text-zinc-400 hover:text-white transition-colors" >
<User size={ 22 } strokeWidth = { 2.5} />
</button>
{/* Right Item (Settings/More) */ }
<button className="p-3 text-zinc-400 hover:text-white transition-colors" >
<div className="w-1 h-1 bg-current rounded-full mb-1" />
<div className="w-1 h-1 bg-current rounded-full" />
</button>
</div>
</div>
);
};
Aufgabe: Setze die bestehenden Views(Dashboard und Liste) mit diesen neuen "Industrial Dark" Regeln um.Sorge für klare Kontraste durch zinc - 950 vs zinc - 900 Flächen.
Was dieser Look ändert:
Lesbarkeit: Durch den zinc - 900 Hintergrund der Karten hebt sich der weiße Text extrem gut ab.Kein "Text auf unruhigem Foto" - Problem mehr.
Modernität: Das "Pill" - Menü(Capsule) sieht aus wie bei modernen iOS Apps(Dynamic Island Ästhetik).
Ehrlichkeit: Es versucht nicht, "altes Geld"(Gold / Serifen) zu imitieren, sondern wirkt wie ein modernes Werkzeug für Enthusiasten.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -17,6 +17,7 @@
"@supabase/ssr": "^0.5.2", "@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"browser-image-compression": "^2.0.2",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"dexie": "^4.2.1", "dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "^4.2.0",

15
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.62.7 specifier: ^5.62.7
version: 5.90.12(react@19.2.3) version: 5.90.12(react@19.2.3)
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
canvas-confetti: canvas-confetti:
specifier: ^1.9.3 specifier: ^1.9.3
version: 1.9.4 version: 1.9.4
@@ -1295,6 +1298,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
browser-image-compression@2.0.2:
resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==}
browserslist@4.28.1: browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -2854,6 +2860,9 @@ packages:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true hasBin: true
uzip@0.20201231.0:
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -4106,6 +4115,10 @@ snapshots:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
browser-image-compression@2.0.2:
dependencies:
uzip: 0.20201231.0
browserslist@4.28.1: browserslist@4.28.1:
dependencies: dependencies:
baseline-browser-mapping: 2.9.9 baseline-browser-mapping: 2.9.9
@@ -5945,6 +5958,8 @@ snapshots:
uuid@13.0.0: {} uuid@13.0.0: {}
uzip@0.20201231.0: {}
victory-vendor@37.3.6: victory-vendor@37.3.6:
dependencies: dependencies:
'@types/d3-array': 3.2.2 '@types/d3-array': 3.2.2

View File

@@ -41,7 +41,7 @@ export default function BottlePage() {
if (!bottleId) return null; if (!bottleId) return null;
return ( 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"> <div className="max-w-4xl mx-auto flex justify-end">
<OfflineIndicator /> <OfflineIndicator />
</div> </div>

View File

@@ -25,6 +25,18 @@ body {
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; 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, h1,
h2, h2,
h3, h3,

View File

@@ -12,7 +12,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext"; import { useI18n } from "@/i18n/I18nContext";
import { useSession } from "@/context/SessionContext"; import { useSession } from "@/context/SessionContext";
import { Sparkles, X } from "lucide-react"; import { Sparkles, X, Loader2 } from "lucide-react";
import { BottomNavigation } from '@/components/BottomNavigation'; import { BottomNavigation } from '@/components/BottomNavigation';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
@@ -25,10 +25,15 @@ export default function Home() {
const { t } = useI18n(); const { t } = useI18n();
const { activeSession } = useSession(); const { activeSession } = useSession();
const [isFlowOpen, setIsFlowOpen] = useState(false); 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) => { useEffect(() => {
setCapturedImage(base64); setHasMounted(true);
}, []);
const handleImageSelected = (file: File) => {
setCapturedFile(file);
setIsFlowOpen(true); setIsFlowOpen(true);
}; };
@@ -149,6 +154,14 @@ export default function Home() {
await supabase.auth.signOut(); 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) { if (!user) {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950"> <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 <ScanAndTasteFlow
isOpen={isFlowOpen} isOpen={isFlowOpen}
onClose={() => setIsFlowOpen(false)} onClose={() => setIsFlowOpen(false)}
base64Image={capturedImage} imageFile={capturedFile}
/> />
</main> </main>
); );

View File

@@ -188,29 +188,29 @@ export default function SessionDetailPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-black"> <div className="min-h-screen flex items-center justify-center bg-zinc-950">
<Loader2 size={48} className="animate-spin text-amber-600" /> <Loader2 size={48} className="animate-spin text-orange-600" />
</div> </div>
); );
} }
if (!session) { if (!session) {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-50 dark:bg-black p-6"> <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">Session nicht gefunden</h1> <h1 className="text-2xl font-bold text-zinc-50">Session nicht gefunden</h1>
<Link href="/" className="text-amber-600 font-bold">Zurück zum Start</Link> <Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
</div> </div>
); );
} }
return ( 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"> <div className="max-w-4xl mx-auto space-y-8">
{/* Back Button */} {/* Back Button */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Link <Link
href="/" 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} /> <ChevronLeft size={16} />
Alle Sessions Alle Sessions
@@ -219,7 +219,7 @@ export default function SessionDetailPage() {
</div> </div>
{/* Hero */} {/* 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 */} {/* Visual Eyecatcher: Background Glow */}
{tastings.length > 0 && tastings[0].bottles.image_url && ( {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"> <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 */} {/* Visual Eyecatcher: Bottle Preview */}
{tastings.length > 0 && tastings[0].bottles.image_url && ( {tastings.length > 0 && tastings[0].bottles.image_url && (
<div className="shrink-0 relative"> <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 <img
src={tastings[0].bottles.image_url} src={tastings[0].bottles.image_url}
alt={tastings[0].bottles.name} 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 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>
<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 LATEST
</div> </div>
</div> </div>
@@ -255,7 +255,7 @@ export default function SessionDetailPage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-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} /> <Sparkles size={14} />
Tasting Session Tasting Session
</div> </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> <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> </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} {session.name}
</h1> </h1>
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-zinc-500 font-bold text-sm"> <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"> <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-amber-600" /> <Calendar size={16} className="text-orange-600" />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')} {new Date(session.scheduled_at).toLocaleDateString('de-DE')}
</span> </span>
{participants.length > 0 && ( {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"> <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-amber-600" /> <Users size={16} className="text-orange-600" />
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} /> <AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
</div> </div>
)} )}
{tastings.length > 0 && ( {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"> <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-amber-600" /> <GlassWater size={16} className="text-orange-600" />
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'} {tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'}
</span> </span>
)} )}
@@ -292,7 +292,7 @@ export default function SessionDetailPage() {
activeSession?.id !== session.id ? ( activeSession?.id !== session.id ? (
<button <button
onClick={() => setActiveSession({ id: session.id, name: session.name })} 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" /> <Play size={18} fill="currentColor" />
Starten Starten
@@ -301,7 +301,7 @@ export default function SessionDetailPage() {
<button <button
onClick={handleCloseSession} onClick={handleCloseSession}
disabled={isClosing} 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" />} {isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={18} className="text-red-500 group-hover:text-white transition-colors" fill="currentColor" />}
Beenden Beenden
@@ -313,7 +313,7 @@ export default function SessionDetailPage() {
onClick={handleDeleteSession} onClick={handleDeleteSession}
disabled={isDeleting} disabled={isDeleting}
title="Session löschen" 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} />} {isDeleting ? <Loader2 size={20} className="animate-spin" /> : <Trash2 size={20} />}
</button> </button>
@@ -324,9 +324,9 @@ export default function SessionDetailPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Sidebar: Participants */} {/* Sidebar: Participants */}
<aside className="md:col-span-1 space-y-6"> <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"> <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-400 mb-6 flex items-center gap-2"> <h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 mb-6 flex items-center gap-2">
<Users size={16} className="text-amber-600" /> <Users size={16} className="text-orange-600" />
Teilnehmer Teilnehmer
</h3> </h3>
@@ -336,10 +336,10 @@ export default function SessionDetailPage() {
) : ( ) : (
participants.map((p) => ( participants.map((p) => (
<div key={p.buddy_id} className="flex items-center justify-between group"> <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 <button
onClick={() => handleRemoveParticipant(p.buddy_id)} 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} /> <Trash2 size={14} />
</button> </button>
@@ -348,14 +348,14 @@ export default function SessionDetailPage() {
)} )}
</div> </div>
<div className="border-t border-zinc-100 dark:border-zinc-800 pt-6"> <div className="border-t border-zinc-800 pt-6">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 block mb-3">Buddy hinzufügen</label> <label className="text-[10px] font-black uppercase tracking-widest text-zinc-500 block mb-3">Buddy hinzufügen</label>
<select <select
onChange={(e) => { onChange={(e) => {
if (e.target.value) handleAddParticipant(e.target.value); if (e.target.value) handleAddParticipant(e.target.value);
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> <option value="">Auswählen...</option>
{allBuddies {allBuddies
@@ -382,15 +382,15 @@ export default function SessionDetailPage() {
{/* Main Content: Bottle List */} {/* Main Content: Bottle List */}
<section className="md:col-span-2 space-y-6"> <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"> <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"> <h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 flex items-center gap-2">
<GlassWater size={16} className="text-amber-600" /> <GlassWater size={16} className="text-orange-600" />
Verkostete Flaschen Verkostete Flaschen
</h3> </h3>
<Link <Link
href={`/?session_id=${id}`} // Redirect to home with context 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} /> <Plus size={16} />
Flasche hinzufügen Flasche hinzufügen

View File

@@ -42,15 +42,15 @@ export default function AuthForm() {
}; };
return ( 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="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"> <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-amber-600" size={32} /> : <UserPlus className="text-amber-600" size={32} />} {isLogin ? <LogIn className="text-orange-600" size={32} /> : <UserPlus className="text-orange-600" size={32} />}
</div> </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'} {isLogin ? 'Willkommen zurück' : 'Vault erstellen'}
</h2> </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 {isLogin
? 'Logge dich ein, um auf deine Sammlung zuzugreifen.' ? 'Logge dich ein, um auf deine Sammlung zuzugreifen.'
: 'Starte heute mit deinem digitalen Whisky-Vault.'} : 'Starte heute mit deinem digitalen Whisky-Vault.'}
@@ -59,7 +59,7 @@ export default function AuthForm() {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <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"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} /> <Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input <input
@@ -68,13 +68,13 @@ export default function AuthForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de" placeholder="name@beispiel.de"
required 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> </div>
<div className="space-y-2"> <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"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input <input
@@ -83,20 +83,20 @@ export default function AuthForm() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required 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> </div>
{error && ( {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} /> <AlertCircle size={16} />
{error} {error}
</div> </div>
)} )}
{message && ( {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} {message}
</div> </div>
)} )}
@@ -104,7 +104,7 @@ export default function AuthForm() {
<button <button
type="submit" type="submit"
disabled={loading} 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')} {loading ? <Loader2 className="animate-spin" size={20} /> : (isLogin ? 'Einloggen' : 'Konto erstellen')}
</button> </button>
@@ -112,8 +112,9 @@ export default function AuthForm() {
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<button <button
type="button"
onClick={() => setIsLogin(!isLogin)} 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'} {isLogin ? 'Noch kein Konto? Registrieren' : 'Bereits ein Konto? Einloggen'}
</button> </button>

View File

@@ -33,11 +33,11 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
if (!bottle && !loading) { if (!bottle && !loading) {
return ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-6 p-6 text-center"> <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} /> <WifiOff size={40} />
</div> </div>
<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"> <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. Inhalte konnten nicht geladen werden. Bitte stelle eine Internetverbindung her, um diese Flasche zum ersten Mal zu laden.
</p> </p>

View File

@@ -9,7 +9,7 @@ interface BottomNavigationProps {
onShelf?: () => void; onShelf?: () => void;
onSearch?: () => void; onSearch?: () => void;
onProfile?: () => void; onProfile?: () => void;
onScan: (base64: string) => void; onScan: (file: File) => void;
} }
export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan }: BottomNavigationProps) => { 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 handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const reader = new FileReader(); onScan(file);
reader.onloadend = () => {
onScan(reader.result as string);
};
reader.readAsDataURL(file);
} }
}; };

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef, useState } from 'react'; 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 { createClient } from '@/lib/supabase/client';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -19,6 +19,7 @@ import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext'; import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format'; import { shortenCategory } from '@/lib/format';
import { magicScan } from '@/services/magic-scan'; import { magicScan } from '@/services/magic-scan';
import { processImageForAI } from '@/utils/image-processing';
interface CameraCaptureProps { interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void; onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void; onAnalysisComplete?: (data: BottleMetadata) => void;
@@ -67,17 +68,33 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini'); 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(() => { React.useEffect(() => {
const checkAdmin = async () => { const checkAdmin = async () => {
try {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (user) { if (user) {
const { data } = await supabase const { data, error } = await supabase
.from('admin_users') .from('admin_users')
.select('role') .select('role')
.eq('user_id', user.id) .eq('user_id', user.id)
.maybeSingle(); .maybeSingle();
if (error) {
console.error('[CameraCapture] Admin check error:', error);
}
console.log('[CameraCapture] Admin status:', !!data);
setIsAdmin(!!data); setIsAdmin(!!data);
} }
} catch (err) {
console.error('[CameraCapture] checkAdmin failed:', err);
}
}; };
checkAdmin(); checkAdmin();
}, [supabase]); }, [supabase]);
@@ -91,6 +108,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setAnalysisResult(null); setAnalysisResult(null);
setIsQueued(false); setIsQueued(false);
setMatchingBottle(null); setMatchingBottle(null);
setPerfMetrics(null);
try { try {
let fileToProcess = file; let fileToProcess = file;
@@ -115,7 +133,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setOriginalFile(fileToProcess); 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); setPreviewUrl(compressedBase64);
if (onImageCaptured) { if (onImageCaptured) {
@@ -136,8 +158,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return; return;
} }
const startAi = performance.now();
const response = await magicScan(compressedBase64, aiProvider, locale); const response = await magicScan(compressedBase64, aiProvider, locale);
const endAi = performance.now();
const startPrep = performance.now();
if (response.success && response.data) { if (response.success && response.data) {
setAnalysisResult(response.data); setAnalysisResult(response.data);
@@ -158,6 +183,16 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
if (onAnalysisComplete) { if (onAnalysisComplete) {
onAnalysisComplete(response.data); onAnalysisComplete(response.data);
} }
const endPrep = performance.now();
if (isAdmin) {
setPerfMetrics({
compression: endComp - startComp,
ai: endAi - startAi,
prep: endPrep - startPrep
});
}
} else { } else {
// If scan fails but it looks like a network issue, offer to queue // If scan fails but it looks like a network issue, offer to queue
const isNetworkError = !navigator.onLine || const isNetworkError = !navigator.onLine ||
@@ -205,21 +240,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
throw new Error(t('camera.authRequired')); 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) { if (response.success && response.data) {
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`; 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')); 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) { if (response.success && response.data) {
setLastSavedId(response.data.id); 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 = () => { const triggerUpload = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
@@ -350,22 +323,22 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
}; };
return ( 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 flex-col w-full gap-1">
<div className="flex items-center justify-between w-full"> <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 && ( {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 <button
onClick={() => setAiProvider('gemini')} 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 Gemini
</button> </button>
<button <button
onClick={() => setAiProvider('mistral')} 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 🇪🇺 Mistral 3 🇪🇺
</button> </button>
@@ -373,10 +346,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} )}
</div> </div>
{activeSession && ( {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"> <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="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-amber-500"></span> <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-orange-500"></span>
</div> </div>
{activeSession.name} {activeSession.name}
</div> </div>
@@ -384,26 +357,48 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
</div> </div>
<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} onClick={triggerUpload}
> >
{previewUrl ? ( {previewUrl ? (
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" /> <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} /> <Camera size={48} strokeWidth={1.5} />
<span className="text-sm font-medium">{t('camera.scanBottle')}</span> <span className="text-sm font-medium">{t('camera.scanBottle')}</span>
</div> </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 && ( {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="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"> <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" /> <Wand2 size={20} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
</div> </div>
<div className="space-y-1"> <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"> <p className="text-sm font-bold">
{!navigator.onLine ? 'Offline: Speichere lokal...' : 'Analysiere Flasche...'} {!navigator.onLine ? 'Offline: Speichere lokal...' : 'Analysiere Flasche...'}
</p> </p>
@@ -453,7 +448,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{!wbDiscovery && !isDiscovering && ( {!wbDiscovery && !isDiscovering && (
<button <button
onClick={handleDiscoverWb} 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} /> <Search size={16} />
{t('camera.whiskybaseSearch')} {t('camera.whiskybaseSearch')}
@@ -468,17 +463,17 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} )}
{wbDiscovery && ( {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="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-amber-600"> <div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-orange-600">
<Sparkles size={12} /> {t('camera.wbMatchFound')} <Sparkles size={12} /> {t('camera.wbMatchFound')}
</div> </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} {wbDiscovery.title}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleLinkWb} 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')} {t('common.link')}
</button> </button>
@@ -486,7 +481,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
href={wbDiscovery.url} href={wbDiscovery.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} <ExternalLink size={12} /> {t('common.check')}
</a> </a>
@@ -500,7 +495,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setAnalysisResult(null); setAnalysisResult(null);
setLastSavedId(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')} {t('camera.later')}
</button> </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"> <div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<Link <Link
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`} 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} /> <ExternalLink size={20} />
{t('camera.toVault')} {t('camera.toVault')}
</Link> </Link>
<button <button
onClick={() => setMatchingBottle(null)} 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')} {t('camera.saveAnyway')}
</button> </button>
@@ -538,7 +533,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
} }
}} }}
disabled={isProcessing || isSaving} 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 ? ( {isSaving ? (
<> <>
@@ -553,7 +548,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
) : previewUrl && analysisResult ? ( ) : previewUrl && analysisResult ? (
validatedSessionId ? ( validatedSessionId ? (
<> <>
<Droplets size={20} className="text-amber-500" /> <Droplets size={20} className="text-orange-500" />
{t('camera.quickTasting')} {t('camera.quickTasting')}
</> </>
) : ( ) : (
@@ -578,7 +573,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{!previewUrl && !isProcessing && ( {!previewUrl && !isProcessing && (
<button <button
onClick={triggerGallery} 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} /> <Upload size={18} />
{t('camera.uploadGallery')} {t('camera.uploadGallery')}
@@ -627,20 +622,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{/* Analysis Results Display */} {/* Analysis Results Display */}
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && ( {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 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} /> <CheckCircle2 size={16} />
{t('camera.analysisSuccess')} {t('camera.analysisSuccess')}
</div> </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="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-amber-600 dark:text-amber-500"> <div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600">
<Sparkles size={18} /> <Sparkles size={18} />
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span> <span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span> <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>
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span> <span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
@@ -675,7 +670,33 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{analysisResult.batch_info && ( {analysisResult.batch_info && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span> <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>
)} )}
</div> </div>

View File

@@ -107,13 +107,13 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
onClick={() => setIsEditing(true)} 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} /> <Edit2 size={16} />
{t('bottle.editDetails')} {t('bottle.editDetails')}
</button> </button>
{bottle.purchase_price && ( {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} /> <CircleDollarSign size={16} />
{t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })} {t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
</div> </div>
@@ -143,7 +143,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} 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>
<div className="space-y-1"> <div className="space-y-1">
@@ -152,7 +152,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.distillery} value={formData.distillery}
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })} 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>
<div className="space-y-1"> <div className="space-y-1">
@@ -161,7 +161,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.category} value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })} 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>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -172,7 +172,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
step="0.1" step="0.1"
value={formData.abv} value={formData.abv}
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })} 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>
<div className="space-y-1"> <div className="space-y-1">
@@ -181,7 +181,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="number" type="number"
value={formData.age} value={formData.age}
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })} 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>
</div> </div>
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.whiskybase_id} value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })} 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 && ( {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"> <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} href={discoveryResult.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} <ExternalLink size={10} /> {t('common.check')}
</a> </a>
@@ -247,7 +247,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="z.B. 2010" placeholder="z.B. 2010"
value={formData.distilled_at} value={formData.distilled_at}
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })} 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> </div>
@@ -258,7 +258,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="z.B. 2022" placeholder="z.B. 2022"
value={formData.bottled_at} value={formData.bottled_at}
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })} 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> </div>
@@ -269,7 +269,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="z.B. Batch 12 oder L-Code" placeholder="z.B. Batch 12 oder L-Code"
value={formData.batch_info} value={formData.batch_info}
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })} 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>
</div> </div>

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 TastingEditor from './TastingEditor';
import SessionBottomSheet from './SessionBottomSheet'; import SessionBottomSheet from './SessionBottomSheet';
import ResultCard from './ResultCard'; import ResultCard from './ResultCard';
@@ -13,52 +13,101 @@ import { saveTasting } from '@/services/save-tasting';
import { BottleMetadata } from '@/types/whisky'; import { BottleMetadata } from '@/types/whisky';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR'; type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
interface ScanAndTasteFlowProps { interface ScanAndTasteFlowProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; 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 [state, setState] = useState<FlowState>('IDLE');
const [isSessionsOpen, setIsSessionsOpen] = useState(false); const [isSessionsOpen, setIsSessionsOpen] = useState(false);
const { activeSession } = useSession(); const { activeSession } = useSession();
const [processedImage, setProcessedImage] = useState<ProcessedImage | null>(null);
const [tastingData, setTastingData] = useState<any>(null); const [tastingData, setTastingData] = useState<any>(null);
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null); const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const { locale } = useI18n(); const { locale } = useI18n();
const supabase = createClient(); 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 // Trigger scan when open and image provided
useEffect(() => { useEffect(() => {
if (isOpen && base64Image) { if (isOpen && imageFile) {
console.log('[ScanFlow] Starting handleScan...'); console.log('[ScanFlow] Starting handleScan...');
handleScan(base64Image); handleScan(imageFile);
} else if (!isOpen) { } else if (!isOpen) {
setState('IDLE'); setState('IDLE');
setTastingData(null); setTastingData(null);
setBottleMetadata(null); setBottleMetadata(null);
setProcessedImage(null);
setError(null); setError(null);
setIsSaving(false); setIsSaving(false);
} }
}, [isOpen, base64Image]); }, [isOpen, imageFile]);
const handleScan = async (image: string) => { const handleScan = async (file: File) => {
setState('SCANNING'); setState('SCANNING');
setError(null); setError(null);
setPerfMetrics(null);
try { try {
const cleanBase64 = image.split(',')[1] || image; console.log('[ScanFlow] Starting image processing...');
console.log('[ScanFlow] Calling magicScan service...'); const startComp = performance.now();
const result = await magicScan(cleanBase64, 'gemini', locale); 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) { if (result.success && result.data) {
console.log('[ScanFlow] magicScan success'); console.log('[ScanFlow] magicScan success');
setBottleMetadata(result.data); setBottleMetadata(result.data);
const endPrep = performance.now();
if (isAdmin) {
setPerfMetrics({
comp: endComp - startComp,
ai: endAi - startAi,
prep: endPrep - startPrep
});
}
setState('EDITOR'); setState('EDITOR');
} else { } else {
console.error('[ScanFlow] magicScan failure:', result.error); console.error('[ScanFlow] magicScan failure:', result.error);
@@ -72,7 +121,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
}; };
const handleSaveTasting = async (formData: any) => { const handleSaveTasting = async (formData: any) => {
if (!bottleMetadata || !base64Image) return; if (!bottleMetadata || !processedImage) return;
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
@@ -81,8 +130,8 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
const { data: { user } = {} } = await supabase.auth.getUser(); const { data: { user } = {} } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert'); if (!user) throw new Error('Nicht autorisiert');
// 1. Save Bottle // 1. Save Bottle - Use compressed base64 for storage as well
const bottleResult = await saveBottle(bottleMetadata, base64Image, user.id); const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
if (!bottleResult.success || !bottleResult.data) { if (!bottleResult.success || !bottleResult.data) {
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche'); 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 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. 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"> <div className="flex-1 flex flex-col items-center justify-center">
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
@@ -173,6 +222,25 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
</p> </p>
</div> </div>
</motion.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> </div>
)} )}
@@ -210,12 +278,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
> >
<TastingEditor <TastingEditor
bottleMetadata={bottleMetadata} bottleMetadata={bottleMetadata}
image={base64Image} image={processedImage?.base64 || null}
onSave={handleSaveTasting} onSave={handleSaveTasting}
onOpenSessions={() => setIsSessionsOpen(true)} onOpenSessions={() => setIsSessionsOpen(true)}
activeSessionName={activeSession?.name} activeSessionName={activeSession?.name}
activeSessionId={activeSession?.id} 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> </motion.div>
)} )}
@@ -246,7 +326,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanA
balance: tastingData.balance || 85, balance: tastingData.balance || 85,
}} }}
bottleName={bottleMetadata.name || 'Unknown Whisky'} bottleName={bottleMetadata.name || 'Unknown Whisky'}
image={base64Image} image={processedImage?.base64 || null}
onShare={handleShare} onShare={handleShare}
/> />
</motion.div> </motion.div>

View File

@@ -206,32 +206,32 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{activeSession && ( {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="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-amber-600 text-white p-2 rounded-xl"> <div className="bg-orange-600 text-white p-2 rounded-xl">
<Sparkles size={16} /> <Sparkles size={16} />
</div> </div>
<div className="min-w-0"> <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-[10px] font-black uppercase tracking-wider text-orange-500">Recording for Session</p>
<p className="text-xs font-bold text-amber-900 dark:text-amber-200 truncate">{activeSession.name}</p> <p className="text-xs font-bold text-orange-200 truncate">{activeSession.name}</p>
</div> </div>
</div> </div>
)} )}
{showPaletteWarning && ( {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"> <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-amber-500 shrink-0 mt-0.5" /> <AlertTriangle size={20} className="text-orange-500 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-wider text-amber-600">Palette-Checker Warnung</p> <p className="text-[10px] font-black uppercase tracking-wider text-orange-600">Palette-Checker Warnung</p>
<p className="text-xs font-bold text-amber-900 dark:text-amber-200"> <p className="text-xs font-bold text-orange-200">
Dein letzter Dram war "{lastDramInSession?.name}". Dein letzter Dram war "{lastDramInSession?.name}".
</p> </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! 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> </p>
<button <button
type="button" type="button"
onClick={() => setShowPaletteWarning(false)} onClick={() => setShowPaletteWarning(false)}
className="text-[9px] font-black uppercase text-amber-600 underline" className="text-[9px] font-black uppercase text-orange-600 underline"
> >
Ignorieren Ignorieren
</button> </button>
@@ -242,10 +242,10 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> <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')} {t('tasting.rating')}
</label> </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> </div>
<input <input
type="range" type="range"
@@ -253,7 +253,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
max="100" max="100"
value={rating} value={rating}
onChange={(e) => setRating(parseInt(e.target.value))} 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"> <div className="flex justify-between text-[9px] text-zinc-400 font-black uppercase tracking-widest px-1">
<span>Swill</span> <span>Swill</span>
@@ -264,13 +264,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('tasting.overall')}</label> <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 <button
type="button" type="button"
onClick={() => setIsSample(false)} onClick={() => setIsSample(false)}
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${!isSample 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' ? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200' : 'text-zinc-500 hover:text-zinc-200'
}`} }`}
> >
Bottle Bottle
@@ -279,8 +279,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
type="button" type="button"
onClick={() => setIsSample(true)} onClick={() => setIsSample(true)}
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${isSample 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' ? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200' : 'text-zinc-500 hover:text-zinc-200'
}`} }`}
> >
Sample Sample
@@ -290,13 +290,13 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
<div className="space-y-6"> <div className="space-y-6">
{/* Nose Section */} {/* 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-950 rounded-3xl border 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-zinc-900/50 px-5 py-4 border-b 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-orange-950/30 p-2 rounded-xl text-orange-600">
<Wind size={18} /> <Wind size={18} />
</div> </div>
<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')} {t('tasting.nose')}
</h3> </h3>
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Aroma & Eindruck</p> <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)} onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} 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> </div>
</div> </div>
{/* Palate Section */} {/* 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-950 rounded-3xl border 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-zinc-900/50 px-5 py-4 border-b 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-orange-950/30 p-2 rounded-xl text-orange-600">
<Utensils size={18} /> <Utensils size={18} />
</div> </div>
<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')} {t('tasting.palate')}
</h3> </h3>
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Geschmack & Textur</p> <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)} onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} 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> </div>
</div> </div>
{/* Finish Section */} {/* 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-950 rounded-3xl border 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-zinc-900/50 px-5 py-4 border-b 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-orange-950/30 p-2 rounded-xl text-orange-600">
<Droplets size={18} /> <Droplets size={18} />
</div> </div>
<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')} {t('tasting.finish')}
</h3> </h3>
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Abgang & Nachhall</p> <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)} onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} 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> </div>
@@ -411,7 +411,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
{buddies.length > 0 && ( {buddies.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> <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')} {t('tasting.participants')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -421,8 +421,8 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
type="button" type="button"
onClick={() => toggleBuddy(buddy.id)} 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) 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-orange-600 border-orange-600 text-white shadow-orange-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-zinc-800 border-zinc-700 text-zinc-400 hover:border-orange-500/50'
}`} }`}
> >
{selectedBuddyIds.includes(buddy.id) && <Check size={10} />} {selectedBuddyIds.includes(buddy.id) && <Check size={10} />}
@@ -442,7 +442,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
<button <button
type="submit" type="submit"
disabled={loading} 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} /> : ( {loading ? <Loader2 className="animate-spin" size={18} /> : (
<> <>

View File

@@ -48,7 +48,7 @@ export async function analyzeBottleMistral(base64Image: string, tags?: string[],
} }
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY }); 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); const prompt = getSystemPrompt(tags ? tags.join(', ') : 'Keine Tags verfügbar', locale);

View File

@@ -57,7 +57,7 @@ export async function analyzeBottle(base64Image: string, tags?: string[], locale
{ {
inlineData: { inlineData: {
data: base64Data, data: base64Data,
mimeType: 'image/jpeg', mimeType: 'image/webp',
}, },
}, },
{ text: instruction }, { text: instruction },

View File

@@ -26,12 +26,13 @@ export async function saveBottle(
if (!finalImageUrl && base64Image) { if (!finalImageUrl && base64Image) {
const base64Data = base64Image.split(',')[1] || base64Image; const base64Data = base64Image.split(',')[1] || base64Image;
const buffer = Buffer.from(base64Data, 'base64'); 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 const { error: uploadError } = await supabase.storage
.from('bottles') .from('bottles')
.upload(fileName, buffer, { .upload(fileName, buffer, {
contentType: 'image/jpeg', contentType: isWebp ? 'image/webp' : 'image/jpeg',
upsert: true, upsert: true,
}); });

View 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;
}
}