feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard

- BottleGrid: Implement blurred backdrop effect for bottle cards
- Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline
- Smart Scan: Native OCR for Android, Live Text fallback for iOS
- OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history
- Features: Add feature flags in src/config/features.ts
- SQL: Add ocr_logs table migration
- Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
This commit is contained in:
2026-01-18 20:38:48 +01:00
parent 83e852e5fb
commit 9ba0825bcd
46 changed files with 3874 additions and 741 deletions

View File

@@ -6,43 +6,62 @@ import { GlassWater, Square, ArrowRight, Sparkles } from 'lucide-react';
import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
import { motion, AnimatePresence } from 'framer-motion';
export default function ActiveSessionBanner() {
const { activeSession, setActiveSession } = useSession();
const { t } = useI18n();
if (!activeSession) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[100] animate-in slide-in-from-top duration-500">
<div className="bg-orange-600 text-white px-4 py-2 flex items-center justify-between shadow-lg">
<Link
href={`/sessions/${activeSession.id}`}
className="flex items-center gap-3 flex-1 min-w-0"
<AnimatePresence>
{activeSession && (
<motion.div
initial={{ y: 50, opacity: 0, x: '-50%' }}
animate={{ y: 0, opacity: 1, x: '-50%' }}
exit={{ y: 50, opacity: 0, x: '-50%' }}
className="fixed bottom-32 left-1/2 z-[50] w-[calc(100%-2rem)] max-w-sm"
>
<div className="relative shrink-0">
<div className="bg-white/20 p-1.5 rounded-lg">
<Sparkles size={16} className="text-white animate-pulse" />
</div>
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-orange-600 animate-ping" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[9px] font-black uppercase tracking-widest bg-white/20 px-1.5 py-0.5 rounded leading-none text-white whitespace-nowrap">Live Jetzt</span>
<p className="text-[10px] font-black uppercase tracking-wider opacity-90 leading-none truncate">{t('session.activeSession')}</p>
</div>
<p className="text-sm font-bold truncate leading-none">{activeSession.name}</p>
</div>
<ArrowRight size={14} className="opacity-50 ml-1 shrink-0" />
</Link>
<div className="bg-zinc-900/90 backdrop-blur-2xl border border-orange-500/20 rounded-[32px] p-2 flex items-center justify-between shadow-2xl ring-1 ring-white/5 overflow-hidden">
{/* Session Info Link */}
<Link
href={`/sessions/${activeSession.id}`}
className="flex items-center gap-3 px-3 py-2 flex-1 min-w-0 hover:bg-white/5 rounded-2xl transition-colors"
>
<div className="relative shrink-0">
<div className="bg-orange-600/10 p-2.5 rounded-2xl border border-orange-500/20">
<Sparkles size={16} className="text-orange-500" />
</div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-orange-600 rounded-full border-2 border-zinc-900 animate-pulse shadow-[0_0_8px_rgba(234,88,12,0.6)]" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[8px] font-black uppercase tracking-widest text-orange-600 animate-pulse">Live</span>
<p className="text-[9px] font-bold uppercase tracking-wider text-zinc-500 truncate leading-none">{t('session.activeSession')}</p>
</div>
<p className="text-sm font-bold text-zinc-100 truncate leading-none tracking-tight">{activeSession.name}</p>
</div>
</Link>
<button
onClick={() => setActiveSession(null)}
className="ml-4 p-2 hover:bg-white/10 rounded-full transition-colors"
title="End Session"
>
<Square size={20} fill="currentColor" />
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 pr-1">
<Link
href={`/sessions/${activeSession.id}`}
className="p-3 text-zinc-400 hover:text-orange-500 transition-colors"
>
<ArrowRight size={18} />
</Link>
<div className="w-px h-8 bg-zinc-800 mx-1" />
<button
onClick={() => setActiveSession(null)}
className="p-3 text-zinc-600 hover:text-red-500 transition-colors"
title="End Session"
>
<Square size={16} fill="currentColor" className="opacity-40" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect, useRef } from 'react';
import { useImageProcessor } from '@/hooks/useImageProcessor';
import { db } from '@/lib/db';
import { FEATURES } from '@/config/features';
/**
* Global handler for background AI image processing.
* Mount this in root layout to ensure processing continues in background.
* It also scans for unprocessed local images on load.
*/
export default function BackgroundRemovalHandler() {
const { addToQueue } = useImageProcessor();
const hasScannedRef = useRef(false);
useEffect(() => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
if (hasScannedRef.current) return;
hasScannedRef.current = true;
const scanAndQueue = async () => {
try {
// 1. Check pending_scans (offline scans)
const pendingScans = await db.pending_scans
.filter(scan => !scan.bgRemoved)
.toArray();
for (const scan of pendingScans) {
if (scan.imageBase64 && scan.temp_id) {
// Convert base64 back to blob for the worker
const res = await fetch(scan.imageBase64);
const blob = await res.blob();
addToQueue(scan.temp_id, blob);
}
}
// 2. Check cache_bottles (successfully saved bottles)
const cachedBottles = await db.cache_bottles
.filter(bottle => !bottle.bgRemoved)
.limit(10) // Limit to avoid overwhelming on start
.toArray();
for (const bottle of cachedBottles) {
if (bottle.image_url && bottle.id) {
try {
const res = await fetch(bottle.image_url);
const blob = await res.blob();
addToQueue(bottle.id, blob);
} catch (e) {
console.warn(`[BG-Removal] Failed to fetch image for bottle ${bottle.id}:`, e);
}
}
}
} catch (err) {
console.error('[BG-Removal] Initial scan error:', err);
}
};
// Delay slightly to not block initial app boot
const timer = setTimeout(scanAndQueue, 3000);
return () => clearTimeout(timer);
}, [addToQueue]);
return null; // Logic-only component
}

View File

@@ -2,7 +2,7 @@
import React from 'react';
import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2 } from 'lucide-react';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2, TrendingUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { updateBottle } from '@/services/update-bottle';
import { getStorageUrl } from '@/lib/supabase';
@@ -12,6 +12,8 @@ import DeleteBottleButton from '@/components/DeleteBottleButton';
import EditBottleForm from '@/components/EditBottleForm';
import { useBottleData } from '@/hooks/useBottleData';
import { useI18n } from '@/i18n/I18nContext';
import FlavorRadar from './FlavorRadar';
interface BottleDetailsProps {
bottleId: string;
@@ -167,6 +169,47 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
exit={{ opacity: 0, x: 20 }}
className="p-6 md:p-8 space-y-8"
>
{/* Flavor Profile Section */}
{tastings && tastings.some((t: any) => t.flavor_profile) && (
<div className="bg-black/20 rounded-3xl border border-white/5 p-6 space-y-4">
<div className="flex items-center gap-2 px-1">
<TrendingUp size={14} className="text-orange-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Average Flavor Profile</span>
</div>
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="w-full md:w-1/2">
<FlavorRadar
profile={(() => {
const validProfiles = tastings.filter((t: any) => t.flavor_profile).map((t: any) => t.flavor_profile);
const count = validProfiles.length;
return {
smoky: Math.round(validProfiles.reduce((s, p) => s + p.smoky, 0) / count),
fruity: Math.round(validProfiles.reduce((s, p) => s + p.fruity, 0) / count),
spicy: Math.round(validProfiles.reduce((s, p) => s + p.spicy, 0) / count),
sweet: Math.round(validProfiles.reduce((s, p) => s + p.sweet, 0) / count),
floral: Math.round(validProfiles.reduce((s, p) => s + p.floral, 0) / count),
};
})()}
size={220}
/>
</div>
<div className="w-full md:w-1/2 space-y-2">
<p className="text-xs text-zinc-400 leading-relaxed font-medium italic">
Basierend auf {tastings.filter((t: any) => t.flavor_profile).length} Verkostungen. Dieses Diagramm zeigt den durchschnittlichen Charakter dieser Flasche.
</p>
<div className="grid grid-cols-2 gap-2 pt-2">
{['smoky', 'fruity', 'spicy', 'sweet', 'floral'].map(attr => (
<div key={attr} className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-orange-600" />
<span className="text-[9px] font-black uppercase tracking-wider text-zinc-500">{attr}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Fact Grid - Integrated Metadata & Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />

View File

@@ -32,77 +32,91 @@ interface BottleCardProps {
function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n();
const imageUrl = getStorageUrl(bottle.image_url);
return (
<Link
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-800/20 backdrop-blur-sm border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98] flex flex-col"
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]"
>
{/* Image Layer - Clean Split Top */}
<div className="aspect-[4/3] overflow-hidden shrink-0">
<img
src={getStorageUrl(bottle.image_url)}
alt={bottle.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 ease-out"
/>
</div>
{/* === SPOTIFY-STYLE IMAGE SECTION === */}
<div className="relative aspect-[3/4] overflow-hidden">
{/* Info Layer - Clean Split Bottom */}
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-1">
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1">
{bottle.distillery}
</p>
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight">
{bottle.name || t('grid.unknownBottle')}
</h3>
{/* Layer 1: Blurred Backdrop */}
<div className="absolute inset-0 z-0">
<img
src={imageUrl}
alt=""
loading="lazy"
className="w-full h-full object-cover scale-125 blur-[20px] saturate-150 brightness-[0.6]"
/>
{/* Vignette Overlay */}
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(circle, rgba(0,0,0,0) 20%, rgba(0,0,0,0.5) 80%)'
}}
/>
</div>
<div className="space-y-4 pt-2">
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{/* Layer 2: Sharp Foreground Image */}
<div className="absolute inset-[10px] z-10 flex items-center justify-center">
<img
src={imageUrl}
alt={bottle.name}
loading="lazy"
className="max-w-full max-h-full object-contain drop-shadow-[0_10px_20px_rgba(0,0,0,0.5)] group-hover:scale-105 transition-transform duration-500 ease-out"
/>
</div>
{/* Top Overlays */}
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
<div className="absolute top-3 right-3 z-20">
<div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg">
<AlertCircle size={12} />
</div>
</div>
)}
{sessionId && (
<div className="absolute top-3 left-3 z-20 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl">
<PlusCircle size={12} />
ADD
</div>
)}
{/* Bottom Gradient Overlay for Text */}
<div
className="absolute bottom-0 left-0 right-0 z-10 h-32"
style={{
background: 'linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%)'
}}
/>
{/* Info Overlay at Bottom */}
<div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
<p className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em] leading-none mb-1">
{bottle.distillery}
</p>
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight line-clamp-2">
{bottle.name || t('grid.unknownBottle')}
</h3>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)}
</span>
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL
</span>
</div>
{/* Metadata items */}
<div className="flex items-center gap-4 pt-3 border-t border-zinc-800/50 mt-auto">
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Calendar size={12} className="text-zinc-500" />
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
{bottle.last_tasted && (
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Clock size={12} className="text-zinc-500" />
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
)}
</div>
</div>
</div>
{/* Top Overlays */}
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
<div className="absolute top-3 right-3 z-10">
<div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg">
<AlertCircle size={12} />
</div>
</div>
)}
{sessionId && (
<div className="absolute top-3 left-3 z-10 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl">
<PlusCircle size={12} />
ADD
</div>
)}
</Link>
);
}
interface BottleGridProps {
bottles: any[];
}

View File

@@ -19,6 +19,8 @@ import { shortenCategory } from '@/lib/format';
import { scanLabel } from '@/app/actions/scanner';
import { enrichData } from '@/app/actions/enrich-data';
import { processImageForAI } from '@/utils/image-processing';
import { runCascadeOCR } from '@/services/cascade-ocr';
import { FEATURES } from '@/config/features';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
@@ -64,7 +66,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [isDiscovering, setIsDiscovering] = useState(false);
const [originalFile, setOriginalFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
const [aiProvider, setAiProvider] = useState<'gemini' | 'openrouter'>('gemini');
const [perfMetrics, setPerfMetrics] = useState<{
compression: number;
@@ -159,6 +161,13 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const formData = new FormData();
formData.append('file', processed.file);
// Run Cascade OCR in parallel (for comparison/logging only - doesn't block AI)
if (FEATURES.ENABLE_CASCADE_OCR) {
runCascadeOCR(processed.file).catch(err => {
console.warn('[CameraCapture] Cascade OCR failed:', err);
});
}
const startAi = performance.now();
const response = await scanLabel(formData);
const endAi = performance.now();
@@ -298,10 +307,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
Gemini
</button>
<button
onClick={() => setAiProvider('mistral')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
onClick={() => setAiProvider('openrouter')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'openrouter' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Mistral 3 🇪🇺
Gemma 🇪🇺
</button>
</div>
)}

View File

@@ -36,10 +36,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
name: bottle.name,
distillery: bottle.distillery || '',
category: bottle.category || '',
abv: bottle.abv || 0,
age: bottle.age || 0,
abv: bottle.abv?.toString() || '',
age: bottle.age?.toString() || '',
whiskybase_id: bottle.whiskybase_id || '',
purchase_price: bottle.purchase_price || '',
purchase_price: bottle.purchase_price?.toString() || '',
distilled_at: bottle.distilled_at || '',
bottled_at: bottle.bottled_at || '',
batch_info: bottle.batch_info || '',
@@ -54,8 +54,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
const result = await discoverWhiskybaseId({
name: formData.name,
distillery: formData.distillery,
abv: formData.abv,
age: formData.age,
abv: formData.abv ? parseFloat(formData.abv) : undefined,
age: formData.age ? parseInt(formData.age) : undefined,
distilled_at: formData.distilled_at || undefined,
bottled_at: formData.bottled_at || undefined,
batch_info: formData.batch_info || undefined,
@@ -83,14 +83,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
try {
const response = await updateBottle(bottle.id, {
...formData,
abv: Number(formData.abv),
age: formData.age ? Number(formData.age) : undefined,
purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined,
abv: formData.abv ? parseFloat(formData.abv.replace(',', '.')) : null,
age: formData.age ? parseInt(formData.age) : null,
purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price.replace(',', '.')) : null,
distilled_at: formData.distilled_at || undefined,
bottled_at: formData.bottled_at || undefined,
batch_info: formData.batch_info || undefined,
cask_type: formData.cask_type || undefined,
});
} as any);
if (response.success) {
setIsEditing(false);
@@ -145,22 +145,23 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
<input
type="number"
type="text"
inputMode="decimal"
step="0.1"
value={formData.abv}
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
onChange={(e) => setFormData({ ...formData, abv: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
placeholder="e.g. 46.3"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
<input
type="number"
type="text"
inputMode="numeric"
value={formData.age}
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
onChange={(e) => setFormData({ ...formData, age: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
placeholder="e.g. 12"
/>
</div>
</div>
@@ -196,9 +197,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} ()</label>
<input
type="number"
type="text"
inputMode="decimal"
step="0.01"
placeholder="0.00"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer
} from 'recharts';
interface FlavorProfile {
smoky: number;
fruity: number;
spicy: number;
sweet: number;
floral: number;
}
interface FlavorRadarProps {
profile: FlavorProfile;
size?: number;
showAxis?: boolean;
}
export default function FlavorRadar({ profile, size = 300, showAxis = true }: FlavorRadarProps) {
const data = [
{ subject: 'Smoky', A: profile.smoky, fullMark: 100 },
{ subject: 'Fruity', A: profile.fruity, fullMark: 100 },
{ subject: 'Spicy', A: profile.spicy, fullMark: 100 },
{ subject: 'Sweet', A: profile.sweet, fullMark: 100 },
{ subject: 'Floral', A: profile.floral, fullMark: 100 },
];
return (
<div style={{ width: '100%', height: size }} className="flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
<PolarGrid stroke="#3f3f46" />
<PolarAngleAxis
dataKey="subject"
tick={{ fill: '#71717a', fontSize: 10, fontWeight: 700 }}
/>
{!showAxis && <PolarRadiusAxis axisLine={false} tick={false} />}
{showAxis && (
<PolarRadiusAxis
angle={30}
domain={[0, 100]}
tick={{ fill: '#3f3f46', fontSize: 8 }}
axisLine={false}
/>
)}
<Radar
name="Flavor"
dataKey="A"
stroke="#d97706"
fill="#d97706"
fillOpacity={0.5}
/>
</RadarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,277 @@
'use client';
/**
* Native OCR Scanner Component
*
* Uses the Shape Detection API (TextDetector) for zero-latency,
* zero-download OCR directly from the camera stream.
*
* Only works on Android/Chrome/Edge. iOS uses the Live Text fallback.
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { X, Camera, Loader2, Zap, CheckCircle } from 'lucide-react';
import { useScanFlow } from '@/hooks/useScanFlow';
import { normalizeDistillery } from '@/lib/distillery-matcher';
interface NativeOCRScannerProps {
isOpen: boolean;
onClose: () => void;
onTextDetected: (texts: string[]) => void;
onAutoCapture?: (result: {
rawTexts: string[];
distillery: string | null;
abv: number | null;
age: number | null;
}) => void;
}
// RegEx patterns for auto-extraction
const PATTERNS = {
abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i,
age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i,
};
export default function NativeOCRScanner({
isOpen,
onClose,
onTextDetected,
onAutoCapture
}: NativeOCRScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const animationRef = useRef<number | null>(null);
const { processVideoFrame } = useScanFlow();
const [isStreaming, setIsStreaming] = useState(false);
const [detectedTexts, setDetectedTexts] = useState<string[]>([]);
const [extractedData, setExtractedData] = useState<{
distillery: string | null;
abv: number | null;
age: number | null;
}>({ distillery: null, abv: null, age: null });
const [isAutoCapturing, setIsAutoCapturing] = useState(false);
// Start camera stream
const startStream = useCallback(async () => {
try {
console.log('[NativeOCR] Starting camera stream...');
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsStreaming(true);
console.log('[NativeOCR] Camera stream started');
}
} catch (err) {
console.error('[NativeOCR] Camera access failed:', err);
}
}, []);
// Stop camera stream
const stopStream = useCallback(() => {
console.log('[NativeOCR] Stopping camera stream...');
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsStreaming(false);
setDetectedTexts([]);
}, []);
// Process frames continuously
const processLoop = useCallback(async () => {
if (!videoRef.current || !isStreaming) return;
const texts = await processVideoFrame(videoRef.current);
if (texts.length > 0) {
setDetectedTexts(texts);
onTextDetected(texts);
// Try to extract structured data
const allText = texts.join(' ');
// ABV
const abvMatch = allText.match(PATTERNS.abv);
const abv = abvMatch ? parseFloat(abvMatch[1].replace(',', '.')) : null;
// Age
const ageMatch = allText.match(PATTERNS.age);
const age = ageMatch ? parseInt(ageMatch[1], 10) : null;
// Distillery (fuzzy match)
let distillery: string | null = null;
for (const text of texts) {
if (text.length >= 4 && text.length <= 40) {
const match = normalizeDistillery(text);
if (match.matched) {
distillery = match.name;
break;
}
}
}
setExtractedData({ distillery, abv, age });
// Auto-capture if we have enough data
if (distillery && (abv || age) && !isAutoCapturing) {
console.log('[NativeOCR] Auto-capture triggered:', { distillery, abv, age });
setIsAutoCapturing(true);
if (onAutoCapture) {
onAutoCapture({
rawTexts: texts,
distillery,
abv,
age,
});
}
// Visual feedback before closing
setTimeout(() => {
onClose();
}, 1500);
}
}
// Continue loop (throttled to ~5 FPS for performance)
animationRef.current = window.setTimeout(() => {
requestAnimationFrame(processLoop);
}, 200) as unknown as number;
}, [isStreaming, processVideoFrame, onTextDetected, onAutoCapture, isAutoCapturing, onClose]);
// Start/stop based on isOpen
useEffect(() => {
if (isOpen) {
startStream();
} else {
stopStream();
}
return () => {
stopStream();
};
}, [isOpen, startStream, stopStream]);
// Start processing loop when streaming
useEffect(() => {
if (isStreaming) {
processLoop();
}
}, [isStreaming, processLoop]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent">
<div className="flex items-center gap-2 text-white">
<Zap size={20} className="text-orange-500" />
<span className="font-bold text-sm">Native OCR</span>
</div>
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 text-white hover:bg-white/20"
>
<X size={24} />
</button>
</div>
{/* Video Feed */}
<video
ref={videoRef}
playsInline
muted
className="w-full h-full object-cover"
/>
{/* Scan Overlay */}
<div className="absolute inset-0 pointer-events-none">
{/* Scan Frame */}
<div className="absolute inset-[10%] border-2 border-orange-500/50 rounded-2xl">
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-orange-500 rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-orange-500 rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-orange-500 rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-orange-500 rounded-br-xl" />
</div>
{/* Scanning indicator */}
{isStreaming && !isAutoCapturing && (
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-black/60 rounded-full text-white text-sm">
<Loader2 size={16} className="animate-spin text-orange-500" />
Scanning...
</div>
)}
{/* Auto-capture success */}
{isAutoCapturing && (
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-green-600 rounded-full text-white text-sm">
<CheckCircle size={16} />
Captured!
</div>
)}
</div>
{/* Detected Text Display */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent">
{extractedData.distillery && (
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
<span className="text-white text-sm font-bold">
🏭 {extractedData.distillery}
</span>
</div>
)}
<div className="flex gap-2 flex-wrap mb-2">
{extractedData.abv && (
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
{extractedData.abv}% ABV
</span>
)}
{extractedData.age && (
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
{extractedData.age} Years
</span>
)}
</div>
{detectedTexts.length > 0 && (
<div className="max-h-20 overflow-y-auto">
<p className="text-zinc-400 text-xs">
{detectedTexts.slice(0, 5).join(' • ')}
</p>
</div>
)}
{!detectedTexts.length && isStreaming && (
<p className="text-zinc-500 text-sm text-center">
Point camera at the bottle label
</p>
)}
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { useI18n } from '@/i18n/I18nContext';
import { createClient } from '@/lib/supabase/client';
import { useScanner, ScanStatus } from '@/hooks/useScanner';
import { db } from '@/lib/db';
import { useImageProcessor } from '@/hooks/useImageProcessor';
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
@@ -40,12 +41,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
const [isEnriching, setIsEnriching] = useState(false);
const [aiFallbackActive, setAiFallbackActive] = useState(false);
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
const { addToQueue } = useImageProcessor();
// Use the Gemini-only scanner hook
// Use the AI-powered scanner hook
const scanner = useScanner({
locale,
onComplete: (cloudResult) => {
console.log('[ScanFlow] Gemini complete:', cloudResult);
console.log('[ScanFlow] Gemma complete:', cloudResult);
setBottleMetadata(cloudResult);
// Trigger background enrichment if we have name and distillery
@@ -202,9 +204,15 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
const bottleId = bottleResult.data.id;
// Queue for background removal
if (scanner.processedImage?.file) {
addToQueue(bottleId, scanner.processedImage.file);
}
const tastingNote = {
...formData,
bottle_id: bottleId,
session_id: activeSession?.id,
};
const tastingResult = await saveTasting(tastingNote);
@@ -264,6 +272,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
locale,
metadata: bottleDataToSave as any
});
// Queue for background removal using temp_id
if (scanner.processedImage?.file) {
addToQueue(tempId, scanner.processedImage.file);
}
}
await db.pending_tastings.add({

View File

@@ -1,7 +1,8 @@
'use client';
import React from 'react';
import { Activity, AlertCircle, TrendingUp, Zap } from 'lucide-react';
import { Activity, AlertCircle, CheckCircle, Zap, TrendingUp } from 'lucide-react';
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts';
interface ABVTasting {
id: string;
@@ -16,116 +17,121 @@ interface SessionABVCurveProps {
export default function SessionABVCurve({ tastings }: SessionABVCurveProps) {
if (!tastings || tastings.length < 2) {
return (
<div className="p-6 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800 text-center">
<Activity size={24} className="mx-auto text-zinc-300 mb-2" />
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Kurve wird ab 2 Drams berechnet</p>
<div className="p-8 bg-zinc-900 rounded-3xl border border-dashed border-zinc-800 text-center">
<Activity size={32} className="mx-auto text-zinc-700 mb-3" />
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest leading-relaxed">
Kurve wird ab 2 Drams berechnet
</p>
</div>
);
}
const sorted = [...tastings].sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime());
const data = [...tastings]
.sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime())
.map((t: ABVTasting, i: number) => ({
name: `Dram ${i + 1}`,
abv: t.abv,
timestamp: new Date(t.tasted_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
id: t.id
}));
// Normalize data: Y-axis is ABV (say 40-65 range), X-axis is time or just sequence index
const minAbv = Math.min(...sorted.map(t => t.abv));
const maxAbv = Math.max(...sorted.map(t => t.abv));
const range = Math.max(maxAbv - minAbv, 10); // at least 10 point range for scale
// SVG Dimensions
const width = 400;
const height = 150;
const padding = 20;
const getX = (index: number) => padding + (index * (width - 2 * padding) / (sorted.length - 1));
const getY = (abv: number) => {
const normalized = (abv - (minAbv - 2)) / (range + 4);
return height - padding - (normalized * (height - 2 * padding));
};
const points = sorted.map((t, i) => `${getX(i)},${getY(t.abv)}`).join(' ');
// Check for dangerous slope (sudden high ABV jump)
const hasBigJump = sorted.some((t, i) => i > 0 && t.abv - sorted[i - 1].abv > 10);
const hasBigJump = tastings.some((t: ABVTasting, i: number) => i > 0 && Math.abs(t.abv - tastings[i - 1].abv) > 10);
const avgAbv = (tastings.reduce((acc: number, t: ABVTasting) => acc + t.abv, 0) / tastings.length).toFixed(1);
return (
<div className="bg-zinc-900 rounded-3xl p-5 border border-white/5 shadow-2xl overflow-hidden relative group">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<TrendingUp size={16} className="text-amber-500" />
<div className="bg-zinc-900 rounded-3xl p-6 border border-white/5 shadow-2xl relative group overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-500/10 rounded-xl">
<TrendingUp size={18} className="text-orange-500" />
</div>
<div>
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none">ABV Kurve (Session)</h4>
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Progression</p>
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none mb-1">ABV Progression</h4>
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Intensity</p>
</div>
</div>
{hasBigJump && (
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg animate-pulse">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full">
<AlertCircle size={10} className="text-red-500" />
<span className="text-[8px] font-black text-red-500 uppercase tracking-tighter">Zick-Zack Gefahr</span>
<span className="text-[8px] font-black text-red-500 uppercase tracking-widest">Spike Alert</span>
</div>
)}
</div>
<div className="relative h-[150px] w-full">
{/* Grid Lines */}
<div className="absolute inset-0 flex flex-col justify-between opacity-10 pointer-events-none">
{[1, 2, 3, 4].map(i => <div key={i} className="border-t border-white" />)}
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full drop-shadow-[0_0_15px_rgba(217,119,6,0.2)]">
{/* Gradient under line */}
<defs>
<linearGradient id="curveGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d97706" stopOpacity="0.4" />
<stop offset="100%" stopColor="#d97706" stopOpacity="0" />
</linearGradient>
</defs>
<path
d={`M ${getX(0)} ${height} L ${points} L ${getX(sorted.length - 1)} ${height} Z`}
fill="url(#curveGradient)"
/>
<polyline
points={points}
fill="none"
stroke="#d97706"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-700 ease-out"
/>
{sorted.map((t, i) => (
<g key={t.id} className="group/dot">
<circle
cx={getX(i)}
cy={getY(t.abv)}
r="4"
fill="#d97706"
className="transition-all hover:r-6 cursor-help"
/>
<text
x={getX(i)}
y={getY(t.abv) - 10}
textAnchor="middle"
className="text-[8px] fill-zinc-400 font-black opacity-0 group-hover/dot:opacity-100 transition-opacity"
>
{t.abv}%
</text>
</g>
))}
</svg>
{/* Chart Container */}
<div className="h-[180px] w-full -ml-4">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id="abvGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ea580c" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff05" />
<XAxis
dataKey="name"
hide
/>
<YAxis
domain={['dataMin - 5', 'dataMax + 5']}
hide
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-950 border border-white/10 p-3 rounded-2xl shadow-2xl backdrop-blur-xl">
<p className="text-[10px] font-black text-zinc-500 uppercase tracking-widest mb-1">
{payload[0].payload.name} {payload[0].payload.timestamp}
</p>
<p className="text-xl font-black text-white">
{payload[0].value}% <span className="text-[10px] text-zinc-500">ABV</span>
</p>
</div>
);
}
return null;
}}
/>
<Area
type="monotone"
dataKey="abv"
stroke="#ea580c"
strokeWidth={3}
fillOpacity={1}
fill="url(#abvGradient)"
animationDuration={1500}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/5 pt-4">
<div className="flex flex-col">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Ø Alkohol</span>
<span className="text-sm font-black text-white">{(sorted.reduce((acc, t) => acc + t.abv, 0) / sorted.length).toFixed(1)}%</span>
{/* Stats Footer */}
<div className="mt-6 grid grid-cols-2 gap-4 border-t border-white/5 pt-6">
<div className="flex flex-col gap-1">
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Ø Intensity</span>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-black text-white tracking-tighter">{avgAbv}</span>
<span className="text-[10px] font-bold text-zinc-500 uppercase">%</span>
</div>
</div>
<div className="flex flex-col items-end">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Status</span>
<span className={`text-[10px] font-black uppercase tracking-widest ${hasBigJump ? 'text-red-500' : 'text-green-500'}`}>
{hasBigJump ? 'Instabil' : 'Optimal'}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Flow State</span>
<div className="flex items-center gap-2">
{hasBigJump ? (
<>
<Zap size={14} className="text-red-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-red-500">Aggressive</span>
</>
) : (
<>
<CheckCircle size={14} className="text-green-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-green-500">Smooth</span>
</>
)}
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp, Play, Sparkles } from 'lucide-react';
import Link from 'next/link';
import AvatarStack from './AvatarStack';
import { deleteSession } from '@/services/delete-session';
@@ -182,45 +182,52 @@ export default function SessionList() {
</form>
{isLoading ? (
<div className="flex justify-center py-8 text-zinc-500">
<Loader2 size={24} className="animate-spin" />
<div className="flex justify-center py-12 text-zinc-700">
<Loader2 size={32} className="animate-spin" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8">
<div className="w-14 h-14 mx-auto rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4">
<Calendar size={24} className="text-zinc-500" />
<div className="text-center py-12 bg-zinc-950/50 rounded-[32px] border border-dashed border-zinc-800">
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-900 flex items-center justify-center mb-6 border border-white/5 shadow-inner">
<Calendar size={28} className="text-zinc-700" />
</div>
<p className="text-sm font-bold text-zinc-400 mb-1">Keine Sessions</p>
<p className="text-xs text-zinc-600 max-w-[200px] mx-auto">
Erstelle eine Tasting-Session um mehrere Whiskys zu vergleichen
<p className="text-sm font-black text-zinc-400 mb-2 uppercase tracking-widest">{t('session.noSessions') || 'Keine Sessions'}</p>
<p className="text-[10px] text-zinc-600 font-bold uppercase tracking-tight max-w-[200px] mx-auto leading-relaxed">
Erstelle eine Tasting-Session um deine Drams zeitlich zu ordnen.
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className={`flex items-center justify-between p-4 rounded-2xl border transition-all ${activeSession?.id === session.id
? 'bg-orange-600 border-orange-600 shadow-lg shadow-orange-950/20'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
className={`group relative flex items-center justify-between p-5 rounded-[28px] border transition-all duration-500 overflow-hidden ${activeSession?.id === session.id
? 'bg-orange-500/[0.03] border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
: 'bg-zinc-950/50 border-white/5 hover:border-white/10'
}`}
>
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-1 min-w-0">
<div className={`font-bold text-lg truncate flex items-center gap-2 ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-50'}`}>
{session.name}
{/* Active Glow Decor */}
{activeSession?.id === session.id && (
<div className="absolute top-0 right-0 w-32 h-32 bg-orange-600/10 blur-[60px] -mr-16 -mt-16 pointer-events-none" />
)}
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-2 min-w-0 z-10">
<div className="flex items-center gap-3">
<div className={`font-black text-xl tracking-tight truncate ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-200 group-hover:text-white transition-colors'}`}>
{session.name}
</div>
{session.ended_at && (
<span className={`text-[8px] font-bold uppercase px-1.5 py-0.5 rounded border ${activeSession?.id === session.id ? 'bg-black/10 border-black/20 text-white' : 'bg-zinc-800 border-zinc-700 text-zinc-500'}`}>Closed</span>
<span className="text-[8px] font-black uppercase px-2 py-0.5 rounded-full bg-zinc-800/50 border border-zinc-700/50 text-zinc-500 tracking-widest">Archiv</span>
)}
</div>
<div className={`flex items-center gap-4 text-[10px] font-bold uppercase tracking-widest ${activeSession?.id === session.id ? 'text-white/60' : 'text-zinc-500'}`}>
<span className="flex items-center gap-1">
<Calendar size={12} />
<div className={`flex items-center gap-5 text-[10px] font-black uppercase tracking-[0.15em] ${activeSession?.id === session.id ? 'text-orange-500/80' : 'text-zinc-500'}`}>
<span className="flex items-center gap-2">
<Calendar size={13} strokeWidth={2.5} />
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
{session.whisky_count! > 0 && (
<span className="flex items-center gap-1">
<GlassWater size={12} />
{session.whisky_count} Whiskys
<span className="flex items-center gap-2">
<GlassWater size={13} strokeWidth={2.5} />
{session.whisky_count}
</span>
)}
</div>
@@ -230,34 +237,37 @@ export default function SessionList() {
</div>
)}
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 z-10">
{activeSession?.id !== session.id ? (
!session.ended_at ? (
<button
onClick={() => setActiveSession({ id: session.id, name: session.name })}
className="p-2 bg-zinc-800 text-zinc-50 rounded-xl hover:bg-orange-600 hover:text-white transition-all"
onClick={(e) => {
e.preventDefault();
setActiveSession({ id: session.id, name: session.name });
}}
className="p-3 text-zinc-600 hover:text-orange-500 transition-all hover:scale-110 active:scale-95"
title="Start Session"
>
<GlassWater size={18} />
<Play size={22} fill="currentColor" className="opacity-40" />
</button>
) : (
<div className="p-2 bg-zinc-900 text-zinc-500 rounded-xl border border-zinc-800 opacity-50">
<Check size={18} />
<div className="p-3 text-zinc-800">
<Check size={20} />
</div>
)
) : (
<div className="p-2 bg-black/10 text-white rounded-xl">
<Check size={18} />
<div className="p-3 text-orange-500 animate-pulse">
<Sparkles size={20} />
</div>
)}
<ChevronRight size={20} className={activeSession?.id === session.id ? 'text-white/40' : 'text-zinc-700'} />
<div className="w-px h-8 bg-white/5 mx-1" />
<button
onClick={(e) => handleDeleteSession(e, session.id)}
disabled={!!isDeleting}
className={`p-2 rounded-xl transition-all ${activeSession?.id === session.id
? 'text-white/40 hover:text-white'
: 'text-zinc-600 hover:text-red-500'
}`}
className="p-3 text-zinc-700 hover:text-red-500 transition-all opacity-0 group-hover:opacity-100"
title="Session löschen"
>
{isDeleting === session.id ? (
@@ -266,6 +276,7 @@ export default function SessionList() {
<Trash2 size={18} />
)}
</button>
<ChevronRight size={20} className="text-zinc-800 group-hover:text-zinc-600 transition-colors" />
</div>
</div>
))}

View File

@@ -17,12 +17,14 @@ interface TimelineTasting {
interface SessionTimelineProps {
tastings: TimelineTasting[];
sessionStart?: string;
isBlind?: boolean;
isRevealed?: boolean;
}
// Keywords that indicate a "Peat Bomb"
const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte'];
export default function SessionTimeline({ tastings, sessionStart }: SessionTimelineProps) {
export default function SessionTimeline({ tastings, sessionStart, isBlind, isRevealed }: SessionTimelineProps) {
if (!tastings || tastings.length === 0) {
return (
<div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800">
@@ -51,6 +53,10 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
const currentTime = tastedDate.getTime();
const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60));
const wallClockTime = tastedDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Blind Mode logic
const showDetails = !isBlind || isRevealed;
const displayName = showDetails ? tasting.bottle_name : `Sample ${String.fromCharCode(65 + index)}`;
const isSmoky = checkIsSmoky(tasting);
const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]);
@@ -61,8 +67,8 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
return (
<div key={tasting.id} className="relative group">
{/* Dot */}
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-sm z-10 flex items-center justify-center ${isSmoky ? 'bg-orange-600' : 'bg-zinc-600'}`}>
{isSmoky && <Droplets size={8} className="text-white fill-white" />}
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-sm z-10 flex items-center justify-center ${isSmoky && showDetails ? 'bg-orange-600' : 'bg-zinc-600'}`}>
{isSmoky && showDetails && <Droplets size={8} className="text-white fill-white" />}
</div>
<div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-sm hover:shadow-md transition-shadow group-hover:border-orange-500/30">
@@ -73,31 +79,53 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tight leading-none">
{wallClockTime} ({index === 0 ? 'Start' : `+${diffMinutes}m`})
</span>
{isSmoky && (
{isSmoky && showDetails && (
<span className="bg-orange-900/40 text-orange-500 text-[8px] font-bold px-1.5 py-0.5 rounded-md uppercase tracking-tighter border border-orange-500/20">Peat Bomb</span>
)}
</div>
<Link
href={`/bottles/${tasting.bottle_id}`}
className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight"
>
{tasting.bottle_name}
</Link>
{showDetails ? (
<Link
href={`/bottles/${tasting.bottle_id}`}
className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight"
>
{displayName}
</Link>
) : (
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-[4px] px-2 py-0.5 rounded-md select-none">
Unknown Bottle
</div>
)}
{!showDetails && (
<div className="mt-1 text-purple-500 font-black uppercase text-[12px] tracking-tight">
{displayName}
</div>
)}
<div className="mt-2 flex flex-wrap gap-1">
{tasting.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800">
{tag}
{showDetails ? (
tasting.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800">
{tag}
</span>
))
) : (
<span className="text-[9px] text-zinc-600 bg-zinc-900 px-2 py-0.5 rounded-full border border-zinc-800 italic">
Noten versteckt...
</span>
))}
)}
</div>
</div>
<div className="shrink-0 flex flex-col items-end">
<div className="text-lg font-bold text-zinc-50 leading-none">{tasting.rating}</div>
<div className="text-lg font-bold text-zinc-50 leading-none">
{showDetails ? tasting.rating : '?'}
</div>
<div className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter mt-1">Punkte</div>
</div>
</div>
{wasPreviousSmoky && timeSinceLastDram < 20 && (
{wasPreviousSmoky && timeSinceLastDram < 20 && showDetails && (
<div className="mt-4 p-2 bg-orange-900/10 border border-orange-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1">
<AlertTriangle size={12} className="text-orange-600 shrink-0" />
<p className="text-[9px] text-orange-400 font-bold leading-tight">

View File

@@ -65,6 +65,12 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || '');
// Guessing State (Blind Mode)
const [guessAbv, setGuessAbv] = useState<string>('');
const [guessAge, setGuessAge] = useState<string>('');
const [guessRegion, setGuessRegion] = useState<string>('');
const [isSessionBlind, setIsSessionBlind] = useState(false);
// Section collapse states
const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded);
const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded);
@@ -80,7 +86,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
// Track last seen confidence to detect cloud vs local updates
const lastConfidenceRef = React.useRef<number>(0);
// Sync bottleMetadata prop changes to internal state (for live Gemini updates)
// Sync bottleMetadata prop changes to internal state (for live AI updates)
// Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5)
useEffect(() => {
// Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100)
@@ -143,6 +149,17 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
setSelectedBuddyIds(participants.map(p => p.buddy_id));
}
// Check if session is blind
const { data: sessionData } = await supabase
.from('tasting_sessions')
.select('is_blind')
.eq('id', activeSessionId)
.single();
if (sessionData?.is_blind) {
setIsSessionBlind(true);
}
const { data: lastTastings } = await supabase
.from('tastings')
.select(`
@@ -237,6 +254,10 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
is_sample: isSample,
buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
// Guessing Data
guess_abv: guessAbv ? parseFloat(guessAbv) : null,
guess_age: guessAge ? parseInt(guessAge) : null,
guess_region: guessRegion || null,
// Visual data for ResultCard
// Edited bottle metadata
bottleMetadata: {
@@ -327,90 +348,140 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
{showBottleDetails && (
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
{/* Name */}
<div className="mt-3">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Flaschenname
</label>
<input
type="text"
value={bottleName}
onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Helper to check field confidence */}
{(() => {
const checkConfidence = (field: string) => {
const scores = bottleMetadata.confidence_scores;
if (!scores) return true; // Default to neutral if no scores
const score = scores[field];
return score === undefined || score >= 80;
};
{/* Distillery */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Destillerie
</label>
<input
type="text"
value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
return (
<>
{/* Name */}
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Flaschenname
</label>
{!checkConfidence('name') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input
type="text"
value={bottleName}
onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('name') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* ABV */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Alkohol (ABV %)
</label>
<input
type="number"
step="0.1"
value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Age */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Alter (Jahre)
</label>
<input
type="number"
value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)}
placeholder="12"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
</div>
{/* Distillery */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Destillerie
</label>
{!checkConfidence('distillery') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input
type="text"
value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('distillery') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/>
</div>
{/* Category */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Kategorie
</label>
<input
type="text"
value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Cask Type */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Fass-Typ (Cask)
</label>
<input
type="text"
value={bottleCaskType}
onChange={(e) => setBottleCaskType(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* ABV */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Alkohol (ABV %)
</label>
{!checkConfidence('abv') && (
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
)}
</div>
<input
type="number"
step="0.1"
value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('abv') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/>
</div>
{/* Age */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Alter (Jahre)
</label>
{!checkConfidence('age') && (
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
)}
</div>
<input
type="number"
value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)}
placeholder="12"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('age') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/>
</div>
</div>
{/* Category */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Kategorie
</label>
<input
type="text"
value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Cask Type */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Fass-Typ (Cask)
</label>
{!checkConfidence('cask_type') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input
type="text"
value={bottleCaskType}
onChange={(e) => setBottleCaskType(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('cask_type') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/>
</div>
</>
);
})()}
{/* Vintage */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
@@ -580,6 +651,54 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div>
</button>
{/* Blind Guessing Section */}
{isSessionBlind && (
<div className="bg-purple-900/10 rounded-[32px] p-8 border border-purple-500/30 space-y-8 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
<Sparkles size={80} className="text-purple-500" />
</div>
<div className="relative">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400 mb-1">Experimenteller Gaumen</h3>
<p className="text-2xl font-black text-white tracking-tighter">Was ist im Glas?</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 relative">
<div className="space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätzter ABV (%)</label>
<input
type="number"
step="0.1"
value={guessAbv}
onChange={(e) => setGuessAbv(e.target.value)}
placeholder="z.B. 46.3"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätztes Alter</label>
<input
type="number"
value={guessAge}
onChange={(e) => setGuessAge(e.target.value)}
placeholder="z.B. 12"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Region / Destillerie Tipp</label>
<input
type="text"
value={guessRegion}
onChange={(e) => setGuessRegion(e.target.value)}
placeholder="z.B. Islay / Lagavulin"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all"
/>
</div>
</div>
</div>
)}
{/* Shared Tasting Form Body */}
<TastingFormBody
rating={rating}

View File

@@ -8,6 +8,8 @@ import { useI18n } from '@/i18n/I18nContext';
import { deleteTasting } from '@/services/delete-tasting';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import FlavorRadar from './FlavorRadar';
interface Tasting {
id: string;
@@ -38,6 +40,13 @@ interface Tasting {
}[];
user_id: string;
isPending?: boolean;
flavor_profile?: {
smoky: number;
fruity: number;
spicy: number;
sweet: number;
floral: number;
};
}
interface TastingListProps {
@@ -92,7 +101,8 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
isPending: true,
tasting_buddies: [],
tasting_sessions: undefined,
tasting_tags: []
tasting_tags: [],
flavor_profile: undefined
}))
];
@@ -198,35 +208,44 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 relative">
{/* Visual Divider for MD and up */}
<div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
<div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
<div className={`grid grid-cols-1 ${note.flavor_profile ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6 relative`}>
{note.flavor_profile && (
<div className="md:col-span-1 bg-zinc-950/50 rounded-2xl border border-white/5 p-2 flex flex-col items-center justify-center">
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-[0.2em] mb-1">Flavor Profile</div>
<FlavorRadar profile={note.flavor_profile} size={140} showAxis={false} />
</div>
)}
{note.nose_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Nose</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.nose_notes}
</p>
</div>
)}
{note.palate_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Palate</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.palate_notes}
</p>
</div>
)}
{note.finish_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Finish</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.finish_notes}
</p>
</div>
)}
<div className={`${note.flavor_profile ? 'md:col-span-3' : 'md:col-span-3'} grid grid-cols-1 md:grid-cols-3 gap-6 relative`}>
{/* Visual Divider for MD and up */}
<div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
<div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
{note.nose_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Nose</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.nose_notes}
</p>
</div>
)}
{note.palate_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Palate</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.palate_notes}
</p>
</div>
)}
{note.finish_notes && (
<div className="space-y-1">
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Finish</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed italic border-l-2 border-zinc-100 dark:border-zinc-800 pl-3">
{note.finish_notes}
</p>
</div>
)}
</div>
</div>
{note.tasting_tags && note.tasting_tags.length > 0 && (