feat: optimize scan flow with WebP compression and fix admin metrics visibility
This commit is contained in:
123
.aiideas
123
.aiideas
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +68,32 @@ 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 () => {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
try {
|
||||||
if (user) {
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
const { data } = await supabase
|
if (user) {
|
||||||
.from('admin_users')
|
const { data, error } = await supabase
|
||||||
.select('role')
|
.from('admin_users')
|
||||||
.eq('user_id', user.id)
|
.select('role')
|
||||||
.maybeSingle();
|
.eq('user_id', user.id)
|
||||||
setIsAdmin(!!data);
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[CameraCapture] Admin check error:', error);
|
||||||
|
}
|
||||||
|
console.log('[CameraCapture] Admin status:', !!data);
|
||||||
|
setIsAdmin(!!data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CameraCapture] checkAdmin failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkAdmin();
|
checkAdmin();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} /> : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
80
src/utils/image-processing.ts
Normal file
80
src/utils/image-processing.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import imageCompression from 'browser-image-compression';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the processed image result
|
||||||
|
*/
|
||||||
|
export interface ProcessedImage {
|
||||||
|
file: File; // The compressed WebP file (ready for Supabase storage)
|
||||||
|
base64: string; // The Base64 string (ready for LLM API calls)
|
||||||
|
originalFile: File; // Pass through the original file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a File or Blob object to a Base64 string.
|
||||||
|
*
|
||||||
|
* @param file - The file or blob to convert
|
||||||
|
* @returns A promise that resolves to the Base64 string
|
||||||
|
*/
|
||||||
|
export function fileToBase64(file: File | Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to convert file to Base64 string'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (error) => reject(error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an image file for AI analysis and storage.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* 1. Resize to max 1024x1024 (maintains aspect ratio)
|
||||||
|
* 2. Convert to WebP format
|
||||||
|
* 3. Limit file size to approx 0.4MB
|
||||||
|
* 4. Uses WebWorker to prevent UI freezing
|
||||||
|
*
|
||||||
|
* @param file - The raw File object from an HTML input
|
||||||
|
* @returns A promise that resolves to a ProcessedImage object
|
||||||
|
*/
|
||||||
|
export async function processImageForAI(file: File): Promise<ProcessedImage> {
|
||||||
|
const options = {
|
||||||
|
maxSizeMB: 0.4,
|
||||||
|
maxWidthOrHeight: 1024,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: 'image/webp'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[processImageForAI] Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
// Compress the image
|
||||||
|
const compressedBlob = await imageCompression(file, options);
|
||||||
|
|
||||||
|
// Create a new File object from the compressed Blob with .webp extension
|
||||||
|
const compressedFile = new File(
|
||||||
|
[compressedBlob],
|
||||||
|
file.name.replace(/\.[^/.]+$/, "") + ".webp",
|
||||||
|
{ type: 'image/webp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[processImageForAI] Compressed size: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
// Convert to Base64 for AI API calls
|
||||||
|
const base64 = await fileToBase64(compressedFile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: compressedFile,
|
||||||
|
base64,
|
||||||
|
originalFile: file
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[processImageForAI] Error processing image:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user