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

@@ -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>