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,6 +1,5 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
import { deductCredits } from '@/services/credit-service';
@@ -8,32 +7,6 @@ import { getAllSystemTags } from '@/services/tags';
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment';
// Native Schema Definition for Enrichment Data
const enrichmentSchema = {
description: "Sensory profile and search metadata for whisky",
type: SchemaType.OBJECT as const,
properties: {
suggested_tags: {
type: SchemaType.ARRAY,
description: "Array of suggested aroma/taste tags from the available system tags",
items: { type: SchemaType.STRING },
nullable: true
},
suggested_custom_tags: {
type: SchemaType.ARRAY,
description: "Array of custom dominant notes not in the system tags",
items: { type: SchemaType.STRING },
nullable: true
},
search_string: {
type: SchemaType.STRING,
description: "Optimized search query for Whiskybase discovery",
nullable: true
}
},
required: [],
};
const ENRICHMENT_MODEL = 'google/gemma-3-27b-it';
/**
@@ -107,46 +80,11 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a
throw lastError || new Error('OpenRouter enrichment failed after retries');
}
/**
* Enrich with Gemini
*/
async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: enrichmentSchema as any,
temperature: 0.3,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent(instruction);
const endApi = performance.now();
const responseText = result.response.text();
return {
data: JSON.parse(responseText),
apiTime: endApi - startApi,
responseText: responseText
};
}
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
const provider = getAIProvider();
// Check API key based on provider
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
// Check API key
if (!process.env.OPENROUTER_API_KEY) {
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
}
@@ -203,13 +141,8 @@ Instructions:
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
console.log(`[EnrichData] Using provider: ${provider}`);
let result: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') {
result = await enrichWithOpenRouter(instruction);
} else {
result = await enrichWithGemini(instruction);
}
const result = await enrichWithOpenRouter(instruction);
console.log('[EnrichData] Response:', result.data);
@@ -229,7 +162,7 @@ Instructions:
endpoint: `enrichData_${provider}`,
success: true,
provider: provider,
model: provider === 'openrouter' ? ENRICHMENT_MODEL : 'gemini-2.5-flash',
model: ENRICHMENT_MODEL,
responseText: result.responseText
});

View File

@@ -1,6 +1,5 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
@@ -9,30 +8,6 @@ import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
import { formatWhiskyName } from '@/utils/formatWhiskyName';
import { createHash } from 'crypto';
import sharp from 'sharp';
// Schema for AI extraction
const visionSchema = {
description: "Whisky bottle label metadata extracted from image",
type: SchemaType.OBJECT as const,
properties: {
name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false },
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true },
category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", nullable: true },
abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true },
age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true },
vintage: { type: SchemaType.STRING, description: "Vintage/distillation year", nullable: true },
cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", nullable: true },
distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true },
bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true },
batch_info: { type: SchemaType.STRING, description: "Batch or cask number", nullable: true },
is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false },
confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false },
},
required: ["name", "is_whisky", "confidence"],
};
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
@@ -68,13 +43,11 @@ OUTPUT SCHEMA (Strict JSON):
"confidence": number
}`;
const GEMINI_MODEL = 'gemini-2.5-flash';
export interface ScannerResult {
success: boolean;
data?: BottleMetadata;
error?: string;
provider?: 'gemini' | 'openrouter';
provider?: 'openrouter';
perf?: {
imagePrep?: number;
apiCall: number;
@@ -183,86 +156,57 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
console.log(`[Scanner] Using provider: ${provider}`);
let aiResult: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') {
const client = getOpenRouterClient();
const startApi = performance.now();
const maxRetries = 3;
let lastError: any = null;
let response: any = null;
const client = getOpenRouterClient();
const startApi = performance.now();
const maxRetries = 3;
let lastError: any = null;
let response: any = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
response = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
{ type: 'text', text: VISION_PROMPT },
],
},
],
temperature: 0.1,
max_tokens: 1024,
// @ts-ignore
provider: OPENROUTER_PROVIDER_PREFERENCES,
});
break; // Success!
} catch (err: any) {
lastError = err;
if (err.status === 429 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
if (!response) throw lastError || new Error('OpenRouter response failed after retries');
const content = response.choices[0]?.message?.content || '{}';
let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[jsonMatch.length - 1].trim();
}
aiResult = {
data: JSON.parse(jsonStr),
apiTime: performance.now() - startApi,
responseText: content
};
} else {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: GEMINI_MODEL,
generationConfig: {
responseMimeType: "application/json",
responseSchema: visionSchema as any,
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
response = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
{ type: 'text', text: VISION_PROMPT },
],
},
],
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType } },
{ text: VISION_PROMPT },
]);
const responseText = result.response.text();
aiResult = {
data: JSON.parse(responseText),
apiTime: performance.now() - startApi,
responseText: responseText
};
max_tokens: 1024,
// @ts-ignore
provider: OPENROUTER_PROVIDER_PREFERENCES,
});
break; // Success!
} catch (err: any) {
lastError = err;
if (err.status === 429 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
if (!response) throw lastError || new Error('OpenRouter response failed after retries');
const content = response.choices[0]?.message?.content || '{}';
let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[jsonMatch.length - 1].trim();
}
aiResult = {
data: JSON.parse(jsonStr),
apiTime: performance.now() - startApi,
responseText: content
};
// 6. Name Composition & Normalization
// Use standardized helper to construct the perfect name
console.log(`[Uncleaned Data]: ${JSON.stringify(aiResult.data)}`);
@@ -301,7 +245,7 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
endpoint: `analyzeBottleLabel_${provider}`,
success: true,
provider,
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : GEMINI_MODEL,
model: OPENROUTER_VISION_MODEL,
responseText: aiResult.responseText
});
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);

View File

@@ -0,0 +1,255 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import { getOcrLogs, getOcrStats } from '@/services/save-ocr-log';
import { Eye, Camera, TrendingUp, CheckCircle, AlertCircle, Calendar, Clock, Percent } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
export default async function OcrLogsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch OCR data
const [logsResult, stats] = await Promise.all([
getOcrLogs(100),
getOcrStats(),
]);
const logs = logsResult.data || [];
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">OCR Dashboard</h1>
<p className="text-zinc-500 mt-1">Mobile OCR Scan Results</p>
</div>
<div className="flex gap-3">
<Link
href="/admin"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
>
Back to Admin
</Link>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Total Scans</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.totalScans}</div>
<div className="text-xs text-zinc-500 mt-1">All time</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.todayScans}</div>
<div className="text-xs text-zinc-500 mt-1">Scans today</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Avg Confidence</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.avgConfidence}%</div>
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Top Distillery</span>
</div>
<div className="text-xl font-black text-zinc-900 dark:text-white truncate">
{stats.topDistilleries[0]?.name || '-'}
</div>
<div className="text-xs text-zinc-500 mt-1">
{stats.topDistilleries[0] ? `${stats.topDistilleries[0].count} scans` : 'No data'}
</div>
</div>
</div>
{/* Top Distilleries */}
{stats.topDistilleries.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map((d, i) => (
<span
key={d.name}
className={`px-3 py-1.5 rounded-full text-sm font-bold ${i === 0
? 'bg-orange-600 text-white'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300'
}`}
>
{d.name} ({d.count})
</span>
))}
</div>
</div>
)}
{/* OCR Logs Grid */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
{logs.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
<Camera className="mx-auto mb-3" size={48} />
<p>No OCR scans recorded yet</p>
<p className="text-sm mt-1">Scans from mobile devices will appear here</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logs.map((log: any) => (
<div
key={log.id}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors"
>
{/* Image Preview */}
<div className="relative aspect-[4/3] rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
{log.image_thumbnail ? (
<img
src={log.image_thumbnail}
alt="Scan"
className="w-full h-full object-cover"
/>
) : log.image_url ? (
<img
src={log.image_url}
alt="Scan"
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center h-full text-zinc-400">
<Camera size={32} />
</div>
)}
{/* Confidence Badge */}
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-[10px] font-black ${log.confidence >= 70
? 'bg-green-500 text-white'
: log.confidence >= 40
? 'bg-amber-500 text-white'
: 'bg-red-500 text-white'
}`}>
{log.confidence}%
</div>
</div>
{/* Detected Fields */}
<div className="space-y-2">
{log.distillery && (
<div className="flex items-center gap-2">
<CheckCircle size={14} className="text-green-500" />
<span className="text-sm font-bold text-zinc-900 dark:text-white">
{log.distillery}
</span>
{log.distillery_source && (
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-zinc-500">
{log.distillery_source}
</span>
)}
</div>
)}
{log.bottle_name && (
<div className="text-sm text-zinc-600 dark:text-zinc-400 truncate">
{log.bottle_name}
</div>
)}
<div className="flex flex-wrap gap-1.5">
{log.abv && (
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px] font-bold">
{log.abv}%
</span>
)}
{log.age && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-[10px] font-bold">
{log.age}y
</span>
)}
{log.vintage && (
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded text-[10px] font-bold">
{log.vintage}
</span>
)}
{log.volume && (
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded text-[10px] font-bold">
{log.volume}
</span>
)}
</div>
</div>
{/* Raw Text (Collapsible) */}
{log.raw_text && (
<details className="mt-3">
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
Raw Text
</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
{log.raw_text}
</pre>
</details>
)}
{/* Meta */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-1 text-[10px] text-zinc-400">
<Clock size={12} />
{new Date(log.created_at).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className="text-[10px] text-zinc-400">
{log.profiles?.username || 'Unknown'}
</div>
{log.processing_time_ms && (
<div className="text-[10px] text-zinc-400">
{log.processing_time_ms}ms
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -93,6 +93,12 @@ export default async function AdminPage() {
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
</div>
<div className="flex gap-3">
<Link
href="/admin/ocr-logs"
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
>
OCR Logs
</Link>
<Link
href="/admin/plans"
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"

View File

@@ -12,6 +12,7 @@ import MainContentWrapper from "@/components/MainContentWrapper";
import SyncHandler from "@/components/SyncHandler";
import CookieBanner from "@/components/CookieBanner";
import OnboardingTutorial from "@/components/OnboardingTutorial";
import BackgroundRemovalHandler from "@/components/BackgroundRemovalHandler";
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
@@ -54,6 +55,7 @@ export default function RootLayout({
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<BackgroundRemovalHandler />
<PWARegistration />
<UploadQueue />
{children}

View File

@@ -16,6 +16,7 @@ import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator';
import BulkScanSheet from '@/components/BulkScanSheet';
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
interface Buddy {
id: string;
@@ -34,12 +35,20 @@ interface Session {
name: string;
scheduled_at: string;
ended_at?: string;
is_blind: boolean;
is_revealed: boolean;
user_id: string;
}
interface SessionTasting {
id: string;
rating: number;
tasted_at: string;
blind_label?: string;
guess_abv?: number;
guess_age?: number;
guess_region?: string;
guess_points?: number;
bottles: {
id: string;
name: string;
@@ -57,21 +66,36 @@ interface SessionTasting {
}
export default function SessionDetailPage() {
const { t } = useI18n();
const { t, locale } = useI18n();
const { id } = useParams();
const router = useRouter();
const { activeSession, setActiveSession } = useSession();
const supabase = createClient();
const [session, setSession] = useState<Session | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user, isLoading: isAuthLoading } = useAuth();
const { activeSession, setActiveSession } = useSession();
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
const [isUpdatingBlind, setIsUpdatingBlind] = useState(false);
// New: Direct Scan Flow
const [isScanFlowOpen, setIsScanFlowOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setIsScanFlowOpen(true);
}
};
useEffect(() => {
if (!isAuthLoading && user) {
@@ -131,6 +155,11 @@ export default function SessionDetailPage() {
id,
rating,
tasted_at,
blind_label,
guess_abv,
guess_age,
guess_region,
guess_points,
bottles(id, name, distillery, image_url, abv, category, processing_status),
tasting_tags(tags(name))
`)
@@ -183,21 +212,67 @@ export default function SessionDetailPage() {
const handleCloseSession = async () => {
if (!confirm('Möchtest du diese Session wirklich abschließen?')) return;
setIsClosing(true);
const result = await closeSession(id as string);
if (result.success) {
const { success } = await closeSession(id as string);
if (success) {
if (activeSession?.id === id) {
setActiveSession(null);
}
fetchSessionData();
} else {
alert(result.error);
}
setIsClosing(false);
};
const handleToggleBlindMode = async () => {
if (!session) return;
setIsUpdatingBlind(true);
const { error } = await supabase
.from('tasting_sessions')
.update({ is_blind: !session.is_blind })
.eq('id', id);
if (!error) {
fetchSessionData();
}
setIsUpdatingBlind(false);
};
const handleRevealBlindMode = async () => {
if (!session) return;
if (!confirm('Möchtest du alle Flaschen aufdecken?')) return;
setIsUpdatingBlind(true);
const { error } = await supabase
.from('tasting_sessions')
.update({ is_revealed: true })
.eq('id', id);
if (!error) {
fetchSessionData();
}
setIsUpdatingBlind(false);
};
const calculateGuessPoints = (tasting: SessionTasting) => {
let points = 0;
// ABV Scoring (100 base - 10 per 1% dev)
if (tasting.guess_abv && tasting.bottles.abv) {
const abvDev = Math.abs(tasting.guess_abv - tasting.bottles.abv);
points += Math.max(0, 100 - (abvDev * 10));
}
// Age Scoring (100 base - 5 per year dev)
// Note: bottles table has 'age' as integer
const bottleAge = (tasting.bottles as any).age;
if (tasting.guess_age && bottleAge) {
const ageDev = Math.abs(tasting.guess_age - bottleAge);
points += Math.max(0, 100 - (ageDev * 5));
}
return Math.round(points);
};
const handleDeleteSession = async () => {
if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return;
@@ -233,95 +308,129 @@ export default function SessionDetailPage() {
}
return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-12 lg:p-24">
<div className="max-w-4xl mx-auto space-y-8">
{/* Back Button */}
<main className="min-h-screen bg-[var(--background)] p-4 md:p-12 lg:p-24 pb-32">
<div className="max-w-6xl mx-auto space-y-12">
{/* Back Link & Info */}
<div className="flex justify-between items-center">
<Link
href="/"
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
className="group inline-flex items-center gap-3 text-zinc-500 hover:text-orange-600 transition-all font-black uppercase text-[10px] tracking-[0.3em]"
>
<ChevronLeft size={16} />
<div className="p-2 rounded-full border border-zinc-800 group-hover:border-orange-500/50 transition-colors">
<ChevronLeft size={16} />
</div>
Alle Sessions
</Link>
<OfflineIndicator />
<div className="flex items-center gap-4">
<OfflineIndicator />
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
ref={fileInputRef}
onChange={handleFileSelect}
/>
</div>
</div>
{/* Hero */}
<header className="bg-zinc-900 rounded-3xl p-8 border border-zinc-800 shadow-xl relative overflow-hidden group">
{/* Visual Eyecatcher: Background Glow */}
{/* Immersive Header */}
<header className="relative bg-zinc-900 border border-white/5 rounded-[48px] p-8 md:p-12 shadow-[0_20px_80px_rgba(0,0,0,0.5)] overflow-hidden group">
{/* Background Visuals */}
<div className="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-900 to-black z-0" />
{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-2/3 h-full opacity-30 z-0">
<div
className="absolute inset-0 bg-cover bg-center scale-150 blur-3xl transition-all duration-1000 group-hover:scale-125"
className="absolute inset-0 bg-cover bg-center scale-150 blur-[100px]"
style={{ backgroundImage: `url(${tastings[0].bottles.image_url})` }}
/>
</div>
)}
<div className="absolute top-0 right-0 p-8 opacity-5 text-zinc-400">
<GlassWater size={120} />
</div>
{/* Decorative Rings */}
<div className="absolute -top-24 -right-24 w-96 h-96 border border-orange-500/10 rounded-full z-0" />
<div className="absolute -top-12 -right-12 w-96 h-96 border border-orange-500/5 rounded-full z-0" />
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex-1 flex flex-col md:flex-row gap-6 items-start md:items-center">
{/* Visual Eyecatcher: Bottle Preview */}
{tastings.length > 0 && tastings[0].bottles.image_url && (
<div className="shrink-0 relative">
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-zinc-800 border-2 border-orange-500/20 shadow-2xl overflow-hidden relative group-hover:rotate-3 transition-transform duration-500">
<img
src={tastings[0].bottles.image_url}
alt={tastings[0].bottles.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute -bottom-2 -right-2 bg-orange-600 text-white text-[10px] font-black px-2 py-1 rounded-lg shadow-lg rotate-12">
LATEST
</div>
<div className="relative z-10 flex flex-col lg:flex-row justify-between items-start lg:items-end gap-8">
<div className="space-y-6 flex-1">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-orange-600/10 border border-orange-500/20 rounded-full flex items-center gap-2">
<Sparkles size={12} className="text-orange-500 animate-pulse" />
<span className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em]">Tasting Session</span>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-orange-600 font-black uppercase text-[10px] tracking-widest">
<Sparkles size={14} />
Tasting Session
</div>
{session.ended_at && (
<span className="bg-zinc-100 dark:bg-zinc-800 text-zinc-500 text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest border border-zinc-200 dark:border-zinc-700">Abgeschlossen</span>
)}
</div>
<h1 className="text-4xl md:text-5xl font-black text-zinc-50 tracking-tighter">
{session.name}
</h1>
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-zinc-500 font-bold text-sm">
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
<Calendar size={16} className="text-orange-600" />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
{session.ended_at && (
<span className="px-3 py-1 bg-zinc-800/50 border border-zinc-700/50 rounded-full text-[10px] font-black text-zinc-500 uppercase tracking-[0.2em]">Archiviert</span>
)}
{session.is_blind && (
<span className="px-3 py-1 bg-purple-600/10 border border-purple-500/20 rounded-full flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-purple-500 rounded-full animate-pulse" />
<span className="text-[10px] font-black text-purple-500 uppercase tracking-[0.2em]">Blind Modus</span>
</span>
{participants.length > 0 && (
<div className="flex items-center gap-2 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
<Users size={16} className="text-orange-600" />
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
</div>
)}
{tastings.length > 0 && (
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm transition-all animate-in fade-in slide-in-from-left-2">
<GlassWater size={16} className="text-orange-600" />
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'}
</span>
)}
)}
{session.is_blind && session.is_revealed && (
<span className="px-3 py-1 bg-green-600/10 border border-green-500/20 rounded-full flex items-center gap-2">
<Sparkles size={10} className="text-green-500" />
<span className="text-[10px] font-black text-green-500 uppercase tracking-[0.2em]">Revealed</span>
</span>
)}
</div>
<h1 className="text-5xl md:text-7xl font-black text-white tracking-tighter leading-[0.9]">
{session.name}
</h1>
<div className="flex flex-wrap items-center gap-4 text-zinc-400">
<div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5 shadow-inner">
<Calendar size={16} className="text-orange-600" />
<span className="text-xs font-black uppercase tracking-widest">{new Date(session.scheduled_at).toLocaleDateString('de-DE')}</span>
</div>
{participants.length > 0 && (
<div className="flex items-center gap-3 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
<Users size={16} className="text-orange-600" />
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
</div>
)}
<div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
<GlassWater size={16} className="text-orange-600" />
<span className="text-xs font-black tracking-widest">{tastings.length} {tastings.length === 1 ? 'DRAM' : 'DRAMS'}</span>
</div>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-3 z-20">
{/* Host Controls for Blind Mode */}
{user?.id === session.user_id && !session.ended_at && (
<>
<button
onClick={handleToggleBlindMode}
disabled={isUpdatingBlind}
className={`px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all border ${session.is_blind
? 'bg-purple-600/20 border-purple-500/50 text-purple-400'
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400 hover:border-zinc-500'
}`}
>
{isUpdatingBlind ? <Loader2 size={16} className="animate-spin" /> : <Play size={14} className={session.is_blind ? "fill-purple-400" : ""} />}
Blind Mode
</button>
{session.is_blind && !session.is_revealed && (
<button
onClick={handleRevealBlindMode}
disabled={isUpdatingBlind}
className="px-6 py-4 bg-green-600 hover:bg-green-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-lg shadow-green-950/20"
>
<Sparkles size={16} />
Reveal
</button>
)}
</>
)}
{!session.ended_at && (
activeSession?.id !== session.id ? (
<button
onClick={() => setActiveSession({ id: session.id, name: session.name })}
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"
className="px-8 py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-[0_10px_40px_rgba(234,88,12,0.3)] hover:-translate-y-1 active:translate-y-0"
>
<Play size={18} fill="currentColor" />
Starten
@@ -330,74 +439,87 @@ export default function SessionDetailPage() {
<button
onClick={handleCloseSession}
disabled={isClosing}
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"
className="px-8 py-4 bg-zinc-800/50 backdrop-blur-xl border border-zinc-700/50 text-zinc-100 hover:bg-red-600 hover:border-red-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all hover:shadow-[0_10px_40px_rgba(220,38,38,0.2)]"
>
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={18} className="text-red-500 group-hover:text-white transition-colors" fill="currentColor" />}
Beenden
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={16} className="text-red-500 group-hover:text-white" fill="currentColor" />}
Session Beenden
</button>
)
)}
<button
onClick={handleDeleteSession}
disabled={isDeleting}
title="Session löschen"
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"
className="p-4 bg-zinc-950 border border-white/5 text-zinc-600 hover:text-red-500 rounded-2xl transition-all"
>
{isDeleting ? <Loader2 size={20} className="animate-spin" /> : <Trash2 size={20} />}
{isDeleting ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}
</button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Sidebar: Participants */}
<aside className="md:col-span-1 space-y-6">
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 mb-6 flex items-center gap-2">
<Users size={16} className="text-orange-600" />
Teilnehmer
</h3>
{/* Blind Mode Reveal Leaderboard */}
{session.is_blind && session.is_revealed && (
<section className="bg-purple-900/10 rounded-[40px] p-8 md:p-12 border border-purple-500/30 shadow-2xl relative overflow-hidden">
<div className="absolute top-0 right-0 p-12 opacity-5">
<Sparkles size={120} className="text-purple-500" />
</div>
<div className="space-y-3 mb-6">
{participants.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Noch keine Teilnehmer...</p>
) : (
participants.map((p) => (
<div key={p.buddy_id} className="flex items-center justify-between group">
<span className="text-sm font-bold text-zinc-300">{p.buddies.name}</span>
<button
onClick={() => handleRemoveParticipant(p.buddy_id)}
className="text-zinc-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))
)}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12 relative">
<div className="space-y-1">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400">Leaderboard</h3>
<p className="text-3xl font-black text-white tracking-tight leading-none italic">Die Goldene Nase</p>
</div>
<div className="border-t border-zinc-800 pt-6">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500 block mb-3">Buddy hinzufügen</label>
<select
onChange={(e) => {
if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = "";
}}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold text-zinc-300 outline-none focus:ring-2 focus:ring-orange-500/50"
>
<option value="">Auswählen...</option>
{allBuddies
.filter(b => !participants.some(p => p.buddy_id === b.id))
.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))
}
</select>
<div className="px-4 py-2 bg-purple-600/20 border border-purple-500/30 rounded-2xl text-[10px] font-black text-purple-400 uppercase tracking-widest">
Mystery Revealed
</div>
</div>
{/* ABV Curve */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 relative">
{tastings.map((t, idx) => {
const score = calculateGuessPoints(t);
return (
<div key={t.id} className="bg-black/40 border border-white/5 rounded-[32px] p-6 group hover:border-purple-500/30 transition-all">
<div className="flex justify-between items-start mb-6">
<div className="w-10 h-10 bg-purple-600/20 border border-purple-500/20 rounded-full flex items-center justify-center text-xs font-black text-purple-400">
{String.fromCharCode(65 + idx)}
</div>
<div className="text-right">
<div className="text-2xl font-black text-white">{score}</div>
<div className="text-[9px] font-black text-purple-400 uppercase tracking-tighter">Punkte</div>
</div>
</div>
<div className="space-y-4">
<div className="text-[11px] font-black text-zinc-300 uppercase truncate group-hover:text-white transition-colors">
{t.bottles.name}
</div>
<div className="grid grid-cols-2 gap-2 pt-4 border-t border-white/5">
<div>
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Guess</div>
<div className="text-[10px] font-bold text-purple-400">
{t.guess_abv ? `${t.guess_abv}%` : '-'} / {t.guess_age ? `${t.guess_age}y` : '-'}
</div>
</div>
<div className="text-right">
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Reality</div>
<div className="text-[10px] font-bold text-white">
{t.bottles.abv}% / {(t.bottles as any).age || '?'}y
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</section>
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left Rail: Stats & Team */}
<div className="lg:col-span-4 space-y-8 lg:sticky lg:top-12">
{/* ABV Analysis */}
{tastings.length > 0 && (
<SessionABVCurve
tastings={tastings.map(t => ({
@@ -407,33 +529,87 @@ export default function SessionDetailPage() {
}))}
/>
)}
</aside>
{/* Main Content: Bottle List */}
<section className="md:col-span-2 space-y-6">
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
<div className="flex justify-between items-center mb-8">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 flex items-center gap-2">
<GlassWater size={16} className="text-orange-600" />
Verkostete Flaschen
</h3>
<div className="flex gap-2">
{/* Team */}
<div className="bg-zinc-900 rounded-[32px] p-8 border border-white/5 shadow-2xl">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 mb-8 flex items-center justify-between">
<span className="flex items-center gap-2">
<Users size={14} className="text-orange-600" />
Crew
</span>
<span className="opacity-50">{participants.length}</span>
</h3>
<div className="space-y-4 mb-8">
{participants.length === 0 ? (
<p className="text-[10px] text-zinc-600 font-bold uppercase italic tracking-wider">Noch keiner an Bord...</p>
) : (
participants.map((p) => (
<div key={p.buddy_id} className="group flex items-center justify-between p-3 rounded-2xl hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-[10px] font-black text-orange-500 border border-white/5 uppercase">
{p.buddies.name[0]}
</div>
<span className="text-sm font-black text-zinc-200">{p.buddies.name}</span>
</div>
<button
onClick={() => handleRemoveParticipant(p.buddy_id)}
className="p-2 text-zinc-700 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="pt-8 border-t border-white/5">
<p className="text-[8px] font-black uppercase tracking-widest text-zinc-600 mb-4 ml-1">Buddy hinzufügen</p>
<select
onChange={(e) => {
if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = "";
}}
className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-[10px] font-black uppercase tracking-wider text-zinc-400 outline-none focus:border-orange-500/50 transition-colors appearance-none"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%23a1a1aa\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
>
<option value="">Auswahl...</option>
{allBuddies
.filter(b => !participants.some(p => p.buddy_id === b.id))
.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))
}
</select>
</div>
</div>
</div>
{/* Main Feed: Timeline */}
<div className="lg:col-span-8 space-y-8">
<section className="bg-zinc-900 rounded-[40px] p-8 md:p-12 border border-white/5 shadow-2xl min-h-screen">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12">
<div className="space-y-1">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600">Timeline</h3>
<p className="text-2xl font-black text-white tracking-tight">Verkostungs-Historie</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
{!session.ended_at && (
<button
onClick={() => setIsBulkScanOpen(true)}
className="bg-zinc-800 hover:bg-zinc-700 text-orange-500 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700"
className="flex-1 md:flex-none bg-zinc-950 border border-white/5 hover:border-orange-500/30 text-zinc-400 hover:text-orange-500 px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all group"
>
<Zap size={16} />
<Zap size={14} className="group-hover:animate-pulse" />
Bulk Scan
</button>
)}
<Link
href={`/?session_id=${id}`}
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"
<button
onClick={() => fileInputRef.current?.click()}
className="flex-1 md:flex-none bg-orange-600 hover:bg-orange-500 text-white px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all shadow-xl shadow-orange-950/20"
>
<Plus size={16} />
Flasche
</Link>
</button>
</div>
</div>
@@ -447,24 +623,38 @@ export default function SessionDetailPage() {
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
category: t.bottles.category
}))}
sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at
sessionStart={session.scheduled_at}
isBlind={session.is_blind}
isRevealed={session.is_revealed}
/>
</div>
</section>
</section>
</div>
</div>
</div>
{/* Bulk Scan Sheet */}
<BulkScanSheet
isOpen={isBulkScanOpen}
onClose={() => setIsBulkScanOpen(false)}
sessionId={id as string}
sessionName={session.name}
onSuccess={(bottleIds) => {
onSuccess={() => {
setIsBulkScanOpen(false);
fetchSessionData();
}}
/>
<ScanAndTasteFlow
isOpen={isScanFlowOpen}
onClose={() => {
setIsScanFlowOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
imageFile={selectedFile}
onBottleSaved={() => {
fetchSessionData();
}}
/>
</main>
);
}

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 && (

13
src/config/features.ts Normal file
View File

@@ -0,0 +1,13 @@
export const FEATURES = {
// Global Toggle: Set to false to disable download and processing completely
ENABLE_AI_BG_REMOVAL: false,
// Feathering intensity in pixels (1-3px is usually best for bottles)
BG_REMOVAL_FEATHER_AMOUNT: 2,
// Enable cascade OCR (Native TextDetector → RegEx → Fuzzy Match → window.ai)
ENABLE_CASCADE_OCR: true,
// Enable Smart Scan Flow (Native TextDetector on Android, Live Text fallback on iOS)
ENABLE_SMART_SCAN: true,
};

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { FEATURES } from '../config/features';
import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client';
import { v4 as uuidv4 } from 'uuid';
/**
* Upload processed image to Supabase and update bottle record
*/
async function uploadToSupabase(bottleId: string, blob: Blob): Promise<string | null> {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
console.warn('[ImageProcessor] No user session, skipping Supabase upload');
return null;
}
// Upload to storage
const fileName = `${user.id}/${bottleId}_nobg_${uuidv4()}.png`;
const { error: uploadError } = await supabase.storage
.from('bottles')
.upload(fileName, blob, {
contentType: 'image/png',
upsert: true,
});
if (uploadError) {
console.error('[ImageProcessor] Upload error:', uploadError);
return null;
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('bottles')
.getPublicUrl(fileName);
// Update bottle record with new image URL
const { error: updateError } = await supabase
.from('bottles')
.update({ image_url: publicUrl })
.eq('id', bottleId);
if (updateError) {
console.error('[ImageProcessor] DB update error:', updateError);
return null;
}
console.log(`[ImageProcessor] Uploaded to Supabase: ${bottleId}`);
return publicUrl;
} catch (err) {
console.error('[ImageProcessor] Supabase sync failed:', err);
return null;
}
}
export function useImageProcessor() {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
// Initialize worker
console.log('[ImageProcessor] Initializing worker...');
const worker = new Worker(
'/bg-processor.worker.js',
{ type: 'module' }
);
workerRef.current = worker;
console.log('[ImageProcessor] Worker instance created');
worker.postMessage({ type: 'ping' });
console.log('[ImageProcessor] Sent ping to worker');
worker.onmessage = async (e) => {
if (e.data.type === 'pong') {
console.log('[ImageProcessor] Received pong from worker - Communication OK');
return;
}
const { id, status, blob, error } = e.data;
if (status === 'success' && blob) {
// Convert blob to Base64 for IndexedDB storage
const reader = new FileReader();
reader.onloadend = async () => {
const base64data = reader.result as string;
try {
// Update cache_bottles if it exists there
const cachedBottle = await db.cache_bottles.get(id);
if (cachedBottle) {
await db.cache_bottles.update(id, {
image_url: base64data,
bgRemoved: true,
updated_at: new Date().toISOString()
});
console.log(`[ImageProcessor] Background removed for cached bottle: ${id}`);
// Upload to Supabase (fire and forget, don't block UI)
uploadToSupabase(id, blob).then(url => {
if (url) {
// Update local cache with the new Supabase URL
db.cache_bottles.update(id, { image_url: url });
}
});
}
// Update pending_scans if it exists there by temp_id
const pendingScan = await db.pending_scans.where('temp_id').equals(id).first();
if (pendingScan) {
await db.pending_scans.update(pendingScan.id!, {
imageBase64: base64data,
bgRemoved: true
});
console.log(`[ImageProcessor] Background removed for pending scan: ${id}`);
}
} catch (err) {
console.error('[ImageProcessor] Failed to update DB:', err);
}
};
reader.readAsDataURL(blob);
} else if (status === 'error') {
console.error('[ImageProcessor] Worker error:', error);
}
};
return () => {
worker.terminate();
};
}, []);
const addToQueue = useCallback(async (id: string, imageBlob: Blob) => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL || !workerRef.current) {
console.warn('[ImageProcessor] Background removal disabled or worker not ready');
return;
}
// Check if already processed to avoid redundant work
const cached = await db.cache_bottles.get(id);
if (cached?.bgRemoved) return;
const pending = await db.pending_scans.where('temp_id').equals(id).first();
if (pending?.bgRemoved) return;
workerRef.current.postMessage({ id, imageBlob });
}, []);
return { addToQueue };
}

196
src/hooks/useScanFlow.ts Normal file
View File

@@ -0,0 +1,196 @@
'use client';
/**
* Smart Scan Flow Hook
*
* "Chamäleon Strategy":
* - Branch A (Android/Chrome): Native TextDetector OCR
* - Branch B (iOS/Unsupported): System Keyboard with Live Text
*
* This is separate from the OpenRouter/Gemma cloud workflow.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import '@/types/text-detector.d.ts';
export interface ScanFlowState {
hasNativeOCR: boolean;
isIOS: boolean;
isAndroid: boolean;
isCameraActive: boolean;
isFormOpen: boolean;
detectedTexts: string[];
isProcessing: boolean;
}
export interface UseScanFlowReturn {
state: ScanFlowState;
triggerScan: () => void;
startCamera: () => void;
stopCamera: () => void;
openFormWithFocus: () => void;
processVideoFrame: (video: HTMLVideoElement) => Promise<string[]>;
}
/**
* Hook for smart scan flow with device capability detection
*/
export function useScanFlow(): UseScanFlowReturn {
// Feature detection (run once on mount)
const [state, setState] = useState<ScanFlowState>({
hasNativeOCR: false,
isIOS: false,
isAndroid: false,
isCameraActive: false,
isFormOpen: false,
detectedTexts: [],
isProcessing: false,
});
const detectorRef = useRef<InstanceType<NonNullable<typeof window.TextDetector>> | null>(null);
// Initialize feature detection
useEffect(() => {
if (typeof window === 'undefined') return;
const hasNativeOCR = 'TextDetector' in window;
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
const isAndroid = /Android/.test(ua);
console.log('[ScanFlow] Feature Detection:', {
hasNativeOCR,
isIOS,
isAndroid,
userAgent: ua.substring(0, 50) + '...',
});
setState(prev => ({
...prev,
hasNativeOCR,
isIOS,
isAndroid,
}));
// Initialize TextDetector if available
if (hasNativeOCR && window.TextDetector) {
try {
detectorRef.current = new window.TextDetector();
console.log('[ScanFlow] TextDetector initialized');
} catch (err) {
console.warn('[ScanFlow] Failed to initialize TextDetector:', err);
}
}
}, []);
/**
* Main trigger function - "Chamäleon Strategy"
*/
const triggerScan = useCallback(() => {
if (state.hasNativeOCR) {
// PATH A: Android / Chrome (Automated OCR)
console.log('[ScanFlow] Branch A: Starting Native TextDetector...');
setState(prev => ({ ...prev, isCameraActive: true, isFormOpen: false }));
} else {
// PATH B: iOS / Fallback (System Keyboard)
console.log('[ScanFlow] Branch B: Native OCR missing. Fallback to System Keyboard Flow.');
openFormWithFocus();
}
}, [state.hasNativeOCR]);
/**
* Start camera for native OCR
*/
const startCamera = useCallback(() => {
console.log('[ScanFlow] Starting camera...');
setState(prev => ({ ...prev, isCameraActive: true }));
}, []);
/**
* Stop camera
*/
const stopCamera = useCallback(() => {
console.log('[ScanFlow] Stopping camera...');
setState(prev => ({ ...prev, isCameraActive: false, detectedTexts: [] }));
}, []);
/**
* Open form and auto-focus first input (iOS Live Text path)
*/
const openFormWithFocus = useCallback(() => {
console.log('[ScanFlow] Opening form with auto-focus...');
setState(prev => ({ ...prev, isFormOpen: true, isCameraActive: false }));
// UX Hack: Focus the field after a micro-task to ensure Modal is rendered
setTimeout(() => {
const inputField = document.querySelector('#field-bottle-name') as HTMLInputElement;
if (inputField) {
inputField.focus();
console.log('[ScanFlow] Focused #field-bottle-name for iOS Live Text');
} else {
// Fallback to any input with data-scan-target
const fallback = document.querySelector('[data-scan-target="true"]') as HTMLInputElement;
if (fallback) {
fallback.focus();
console.log('[ScanFlow] Focused fallback scan target');
}
}
}, 150);
}, []);
/**
* Process a video frame using TextDetector
*/
const processVideoFrame = useCallback(async (video: HTMLVideoElement): Promise<string[]> => {
if (!detectorRef.current) {
console.warn('[ScanFlow] TextDetector not available');
return [];
}
setState(prev => ({ ...prev, isProcessing: true }));
try {
const imageBitmap = await createImageBitmap(video);
const detections = await detectorRef.current.detect(imageBitmap);
const texts = detections
.map(d => d.rawValue)
.filter(Boolean)
.filter(text => text.length >= 2); // Filter very short strings
console.log('[ScanFlow] Detected texts:', texts);
setState(prev => ({
...prev,
detectedTexts: texts,
isProcessing: false,
}));
return texts;
} catch (err) {
console.error('[ScanFlow] Frame processing error:', err);
setState(prev => ({ ...prev, isProcessing: false }));
return [];
}
}, []);
return {
state,
triggerScan,
startCamera,
stopCamera,
openFormWithFocus,
processVideoFrame,
};
}
/**
* Utility: Get placeholder text based on device
*/
export function getScanPlaceholder(isIOS: boolean, defaultText: string = 'Bottle Name'): string {
if (isIOS) {
return "Tap here & use 'Scan Text' 📷";
}
return defaultText;
}

View File

@@ -6,6 +6,7 @@ import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
import { analyzeBottleLabel } from '@/app/actions/scanner';
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
import { db } from '@/lib/db';
import { runCascadeOCR } from '@/services/cascade-ocr';
export type ScanStatus =
| 'idle'
@@ -142,14 +143,19 @@ export function useScanner(options: UseScannerOptions = {}) {
},
});
// Step 4.5: Run Cascade OCR in parallel (for comparison/logging only)
runCascadeOCR(processedImage.file).catch(err => {
console.warn('[useScanner] Cascade OCR failed:', err);
});
// Step 5: Run AI in background (user is already in editor!)
const cloudStart = performance.now();
const cloudResponse = await analyzeBottleLabel(processedImage.base64);
perfCloudVision = performance.now() - cloudStart;
// Store provider/model info
providerUsed = cloudResponse.provider || 'unknown';
modelUsed = providerUsed === 'openrouter' ? 'gemma-3-27b-it' : 'gemini-2.5-flash';
providerUsed = cloudResponse.provider || 'openrouter';
modelUsed = 'gemma-3-27b-it';
if (cloudResponse.success && cloudResponse.data) {
const cloudResult = cloudResponse.data;

View File

@@ -70,7 +70,14 @@ OUTPUT SCHEMA (Strict JSON):
"batch_limit": "string (e.g. 348 bottles)",
"abv": number,
"volume": "string",
"confidence": number
"confidence": number,
"confidence_scores": {
"name": number,
"distillery": number,
"abv": number,
"age": number,
"cask_type": number
}
}
`;
@@ -107,3 +114,29 @@ Additionally, provide:
- suggested_custom_tags: string[]
- search_string: string
`;
export const getFlavorRadarPrompt = (nose: string, palate: string, finish: string, tags: string[]) => `
TASK: Analyze the following whisky tasting notes and tags to generate a flavor profile.
NOTES:
Nose: ${nose}
Palate: ${palate}
Finish: ${finish}
Tags: ${tags.join(', ')}
OBJECTIVE: Score the intensity (0-100) for the following 5 dimensions.
Be objective. If a category is not mentioned or implied, score it low (0-10).
1. Smoky (Rauchig, Torf, Peat, Ash, Smoke, Lagerfeuer, Teer)
2. Fruity (Fruchtig, Berry, Citrus, Apple, Sherry-Fruits, Peach, Tropical)
3. Spicy (Würzig, Pepper, Oak, Cinnamon, Clove, Ginger, Nutmeg)
4. Sweet (Süß, Caramel, Vanille, Honey, Chocolate, Toffee, Sugar)
5. Floral (Floral, Heather, Grass, Flowers, Rose, Lavender, Herbs)
OUTPUT JSON:
{
"smoky": number,
"fruity": number,
"spicy": number,
"sweet": number,
"floral": number
}
`;

View File

@@ -5,12 +5,13 @@ export interface PendingScan {
temp_id: string; // Used to link tasting notes before sync
imageBase64: string;
timestamp: number;
provider?: 'gemini' | 'mistral';
provider?: 'gemini' | 'openrouter';
locale?: string;
metadata?: any; // Bottle metadata for offline scans
syncing?: number; // 0 or 1 for indexing
attempts?: number;
last_error?: string;
bgRemoved?: boolean;
}
export interface PendingTasting {
@@ -63,6 +64,7 @@ export interface CachedBottle {
batch_info?: string | null;
created_at: string;
updated_at: string;
bgRemoved?: boolean;
}
export interface CachedTasting {
@@ -90,7 +92,7 @@ export class WhiskyDexie extends Dexie {
constructor() {
super('WhiskyVault');
this.version(6).stores({
this.version(7).stores({
pending_scans: '++id, temp_id, timestamp, locale, syncing, attempts',
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
cache_tags: 'id, category, name',

View File

@@ -1,14 +0,0 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
const apiKey = process.env.GEMINI_API_KEY!;
const genAI = new GoogleGenerativeAI(apiKey);
export const geminiModel = genAI.getGenerativeModel({
model: 'gemma-3-27b',
generationConfig: {
responseMimeType: 'application/json',
},
});
// SYSTEM_INSTRUCTION moved to src/lib/ai-prompts.ts

View File

@@ -7,12 +7,10 @@ import OpenAI from 'openai';
* - "openrouter" (default) - Uses OpenRouter with Gemma 3 27B via Nebius/FP8
* - "gemini" - Uses Google Gemini 2.5 Flash
*/
export type AIProvider = 'openrouter' | 'gemini';
export type AIProvider = 'openrouter';
export function getAIProvider(): AIProvider {
const provider = process.env.AI_PROVIDER?.toLowerCase();
if (provider === 'gemini') return 'gemini';
return 'openrouter'; // Default
return 'openrouter';
}
/**
@@ -38,6 +36,7 @@ export function getOpenRouterClient(): OpenAI {
// Default OpenRouter model for vision tasks
export const OPENROUTER_VISION_MODEL = 'google/gemma-3-27b-it';
//export const OPENROUTER_VISION_MODEL = 'google/gemma-3n-e4b-it';
/**
* OpenRouter provider preferences

View File

@@ -1,6 +1,6 @@
'use server';
import { Mistral } from '@mistralai/mistralai';
import { getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
import { getSystemPrompt } from '@/lib/ai-prompts';
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
@@ -9,9 +9,9 @@ import { trackApiUsage } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse & { search_string?: string }> {
if (!process.env.MISTRAL_API_KEY) {
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
export async function analyzeBottleOpenRouter(input: any): Promise<AnalysisResponse & { search_string?: string }> {
if (!process.env.OPENROUTER_API_KEY) {
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
}
let supabase;
@@ -76,8 +76,8 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
};
}
// 5. Für Mistral vorbereiten
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
// 5. Für OpenRouter vorbereiten
const client = getOpenRouterClient();
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp';
const dataUrl = `data:${mimeType};base64,${base64Data}`;
@@ -87,25 +87,28 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
try {
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
const chatResponse = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
{ type: 'image_url', image_url: { url: dataUrl } }
]
}
} as any
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
response_format: { type: 'json_object' },
temperature: 0.1,
extra_body: {
provider: OPENROUTER_PROVIDER_PREFERENCES
}
} as any);
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
if (!rawContent) throw new Error("Keine Antwort von OpenRouter");
let jsonData;
try {
@@ -116,13 +119,12 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
console.log('[OpenRouter AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (typeof jsonData.abv === 'string') {
// Fix: Global replace to remove all % signs
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
}
@@ -135,14 +137,14 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
endpoint: 'openrouter/gemma',
success: true,
provider: 'mistral',
model: 'mistral-large-latest',
provider: 'openrouter',
model: OPENROUTER_VISION_MODEL,
responseText: rawContent as string
});
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
await deductCredits(userId, 'gemini_ai', 'Gemma AI analysis via OpenRouter');
await supabase
.from('vision_cache')
@@ -161,16 +163,16 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
};
} catch (aiError: any) {
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
console.warn('[OpenRouterAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
endpoint: 'openrouter/gemma',
success: false,
errorMessage: aiError.message,
provider: 'mistral',
model: 'mistral-large-latest'
provider: 'openrouter',
model: OPENROUTER_VISION_MODEL
});
return {
@@ -182,10 +184,10 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
}
} catch (error) {
console.error('Mistral Analysis Global Error:', error);
console.error('OpenRouter Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
error: error instanceof Error ? error.message : 'OpenRouter AI analysis failed.',
};
}
}

384
src/services/cascade-ocr.ts Normal file
View File

@@ -0,0 +1,384 @@
'use client';
/**
* Cascade OCR Service
*
* Waterfall-Prinzip:
* 1. Native TextDetector API (Chrome)
* 2. RegEx für Hard Facts (ABV, Age, Vintage, etc.)
* 3. Fuzzy Match für Distillery
* 4. window.ai für Deduktion (falls vorhanden)
* 5. Fallback: Raw Text
*/
import { FEATURES } from '@/config/features';
import { normalizeDistillery } from '@/lib/distillery-matcher';
import { saveOcrLog } from '@/services/save-ocr-log';
// Types
export interface CascadeOCRResult {
success: boolean;
// Extracted fields
distillery: string | null;
name: string | null;
abv: number | null;
age: number | null;
vintage: string | null;
volume: string | null;
category: string | null;
// Meta
rawText: string;
matchSources: {
distillery: 'fuzzy' | 'ai' | 'manual' | null;
abv: 'regex' | null;
age: 'regex' | null;
};
confidence: number;
processingTimeMs?: number;
savedToDb?: boolean;
}
// RegEx patterns for whisky bottle labels
const PATTERNS = {
// ABV: "40%", "40% vol", "40.5%", "40,5%"
abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i,
// Age: "10 Years", "12 Year Old", "10yo", "10 YO"
age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i,
// Vintage: "Distilled 2010", "2010", "Vintage 2015"
vintage: /(?:distilled|vintage|dist\.?)\s*(\d{4})|^(\d{4})$/im,
// Volume: "700ml", "70cl", "750ml"
volume: /(\d{2,4})\s*(?:ml|cl)/i,
// Cask Strength indicator
caskStrength: /cask\s*strength|fass\s*st[aä]rke|natural\s*strength/i,
// Single Malt indicator
singleMalt: /single\s*malt/i,
// Blend indicator
blend: /blended|blend/i,
};
/**
* Step 1: Native OCR via TextDetector API
*/
async function detectText(imageBlob: Blob): Promise<string[]> {
// Check if TextDetector is available (Chrome only)
if (!('TextDetector' in window)) {
console.log('[CascadeOCR] TextDetector not available');
return [];
}
try {
// @ts-ignore - TextDetector is experimental
const detector = new window.TextDetector();
const imageBitmap = await createImageBitmap(imageBlob);
const detections = await detector.detect(imageBitmap);
const texts = detections.map((d: any) => d.rawValue).filter(Boolean);
console.log('[CascadeOCR] TextDetector found:', texts);
return texts;
} catch (err) {
console.warn('[CascadeOCR] TextDetector failed:', err);
return [];
}
}
/**
* Step 2: Extract hard facts via RegEx
*/
function extractHardFacts(text: string): {
abv: number | null;
age: number | null;
vintage: string | null;
volume: string | null;
category: string | null;
} {
const result = {
abv: null as number | null,
age: null as number | null,
vintage: null as string | null,
volume: null as string | null,
category: null as string | null,
};
// ABV
const abvMatch = text.match(PATTERNS.abv);
if (abvMatch) {
result.abv = parseFloat(abvMatch[1].replace(',', '.'));
console.log('[CascadeOCR] RegEx ABV:', result.abv);
}
// Age
const ageMatch = text.match(PATTERNS.age);
if (ageMatch) {
result.age = parseInt(ageMatch[1], 10);
console.log('[CascadeOCR] RegEx Age:', result.age);
}
// Vintage
const vintageMatch = text.match(PATTERNS.vintage);
if (vintageMatch) {
result.vintage = vintageMatch[1] || vintageMatch[2];
console.log('[CascadeOCR] RegEx Vintage:', result.vintage);
}
// Volume
const volumeMatch = text.match(PATTERNS.volume);
if (volumeMatch) {
result.volume = volumeMatch[0];
console.log('[CascadeOCR] RegEx Volume:', result.volume);
}
// Category
if (PATTERNS.singleMalt.test(text)) {
result.category = 'Single Malt';
} else if (PATTERNS.blend.test(text)) {
result.category = 'Blended';
}
if (result.category) {
console.log('[CascadeOCR] RegEx Category:', result.category);
}
return result;
}
/**
* Step 3a: Fuzzy match distillery
*/
function matchDistillery(textLines: string[]): { name: string; matched: boolean } | null {
// Try each line as a potential distillery name
for (const line of textLines) {
if (line.length < 3 || line.length > 50) continue;
const result = normalizeDistillery(line);
if (result.matched) {
console.log('[CascadeOCR] Fuzzy Distillery Match:', result.name);
return { name: result.name, matched: true };
}
}
return null;
}
/**
* Step 3b: Use window.ai for deduction (if available)
*/
async function deduceWithWindowAI(rawText: string): Promise<{
distillery: string | null;
name: string | null;
category: string | null;
}> {
// Check if window.ai is available
// @ts-ignore
if (!window.ai || !window.ai.languageModel) {
console.log('[CascadeOCR] window.ai not available');
return { distillery: null, name: null, category: null };
}
try {
console.log('[CascadeOCR] Using window.ai for deduction...');
// @ts-ignore
const session = await window.ai.languageModel.create();
const prompt = `Analyze this text from a whisky bottle label and extract:
1. Distillery name (the producer)
2. Bottle name/expression
3. Category (Single Malt, Blended, Bourbon, etc.)
Text: "${rawText}"
Respond in JSON format only:
{"distillery": "...", "name": "...", "category": "..."}`;
const response = await session.prompt(prompt);
console.log('[CascadeOCR] window.ai response:', response);
try {
const parsed = JSON.parse(response);
return {
distillery: parsed.distillery || null,
name: parsed.name || null,
category: parsed.category || null,
};
} catch {
return { distillery: null, name: null, category: null };
}
} catch (err) {
console.warn('[CascadeOCR] window.ai failed:', err);
return { distillery: null, name: null, category: null };
}
}
/**
* Main cascade function
*/
export async function runCascadeOCR(imageBlob: Blob): Promise<CascadeOCRResult> {
if (!FEATURES.ENABLE_CASCADE_OCR) {
console.log('[CascadeOCR] Feature disabled');
return {
success: false,
distillery: null,
name: null,
abv: null,
age: null,
vintage: null,
volume: null,
category: null,
rawText: '',
matchSources: { distillery: null, abv: null, age: null },
confidence: 0,
};
}
console.log('[CascadeOCR] === Starting Cascade OCR ===');
const startTime = performance.now();
// Step 1: Native OCR
const textLines = await detectText(imageBlob);
const rawText = textLines.join('\n');
console.log('[CascadeOCR] Raw text:', rawText);
if (!rawText.trim()) {
console.log('[CascadeOCR] No text detected');
return {
success: false,
distillery: null,
name: null,
abv: null,
age: null,
vintage: null,
volume: null,
category: null,
rawText: '',
matchSources: { distillery: null, abv: null, age: null },
confidence: 0,
};
}
// Step 2: Extract hard facts via RegEx
const hardFacts = extractHardFacts(rawText);
// Step 3a: Fuzzy match distillery
let distilleryResult = matchDistillery(textLines);
let distillerySource: 'fuzzy' | 'ai' | 'manual' | null = distilleryResult?.matched ? 'fuzzy' : null;
// Step 3b: If no fuzzy match, try window.ai
let aiResult = { distillery: null as string | null, name: null as string | null, category: null as string | null };
if (!distilleryResult) {
aiResult = await deduceWithWindowAI(rawText);
if (aiResult.distillery) {
distilleryResult = { name: aiResult.distillery, matched: false };
distillerySource = 'ai';
}
}
// Calculate confidence
let confidence = 0;
if (distilleryResult?.matched) confidence += 40;
else if (distilleryResult) confidence += 20;
if (hardFacts.abv) confidence += 20;
if (hardFacts.age) confidence += 20;
if (hardFacts.category || aiResult.category) confidence += 10;
if (hardFacts.vintage) confidence += 10;
const processingTimeMs = performance.now() - startTime;
const result: CascadeOCRResult = {
success: true,
distillery: distilleryResult?.name || null,
name: aiResult.name || null,
abv: hardFacts.abv,
age: hardFacts.age,
vintage: hardFacts.vintage,
volume: hardFacts.volume,
category: hardFacts.category || aiResult.category || null,
rawText,
matchSources: {
distillery: distillerySource,
abv: hardFacts.abv ? 'regex' : null,
age: hardFacts.age ? 'regex' : null,
},
confidence,
processingTimeMs,
savedToDb: false,
};
console.log('[CascadeOCR] === Final Result ===');
console.log('[CascadeOCR] Distillery:', result.distillery, `(${result.matchSources.distillery})`);
console.log('[CascadeOCR] Name:', result.name);
console.log('[CascadeOCR] ABV:', result.abv, `(${result.matchSources.abv})`);
console.log('[CascadeOCR] Age:', result.age, `(${result.matchSources.age})`);
console.log('[CascadeOCR] Vintage:', result.vintage);
console.log('[CascadeOCR] Volume:', result.volume);
console.log('[CascadeOCR] Category:', result.category);
console.log('[CascadeOCR] Confidence:', result.confidence + '%');
console.log('[CascadeOCR] Processing Time:', processingTimeMs.toFixed(0) + 'ms');
// Save to database (async, non-blocking)
try {
// Create thumbnail for DB storage
let imageThumbnail: string | undefined;
try {
const bitmap = await createImageBitmap(imageBlob);
const canvas = document.createElement('canvas');
const maxSize = 200;
const scale = Math.min(maxSize / bitmap.width, maxSize / bitmap.height);
canvas.width = bitmap.width * scale;
canvas.height = bitmap.height * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
imageThumbnail = canvas.toDataURL('image/jpeg', 0.7);
}
} catch (thumbErr) {
console.warn('[CascadeOCR] Thumbnail creation failed:', thumbErr);
}
const saveResult = await saveOcrLog({
imageThumbnail,
rawText: result.rawText,
detectedTexts: textLines,
distillery: result.distillery,
distillerySource: result.matchSources.distillery,
bottleName: result.name,
abv: result.abv,
age: result.age,
vintage: result.vintage,
volume: result.volume,
category: result.category,
confidence: result.confidence,
deviceInfo: navigator.userAgent,
ocrMethod: 'TextDetector' in window ? 'text_detector' : 'fallback',
processingTimeMs: Math.round(processingTimeMs),
});
if (saveResult.success) {
result.savedToDb = true;
console.log('[CascadeOCR] Saved to DB:', saveResult.id);
} else {
console.warn('[CascadeOCR] DB save failed:', saveResult.error);
}
} catch (dbErr) {
console.error('[CascadeOCR] DB save error:', dbErr);
}
return result;
}
/**
* Hook to use cascade OCR in components
*/
export function useCascadeOCR() {
const processImage = async (imageBlob: Blob) => {
return runCascadeOCR(imageBlob);
};
return { processImage };
}

View File

@@ -0,0 +1,76 @@
'use server';
import { getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
import { getFlavorRadarPrompt } from '@/lib/ai-prompts';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from './track-api-usage';
export async function extractFlavorProfile(nose: string, palate: string, finish: string, tagNames: string[]) {
if (!process.env.OPENROUTER_API_KEY) {
console.warn('OPENROUTER_API_KEY is not configured. Skipping flavor extraction.');
return null;
}
// Don't waste API calls if notes are empty
if (!nose && !palate && !finish && tagNames.length === 0) {
return {
smoky: 0,
fruity: 0,
spicy: 0,
sweet: 0,
floral: 0
};
}
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const client = getOpenRouterClient();
const prompt = getFlavorRadarPrompt(nose, palate, finish, tagNames);
const chatResponse = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.1,
extra_body: {
provider: OPENROUTER_PROVIDER_PREFERENCES
}
} as any);
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von OpenRouter");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
await trackApiUsage({
userId: user.id,
apiType: 'gemini_ai', // Using this category for all LLM calls
endpoint: 'openrouter/flavor-radar',
success: true,
provider: 'openrouter',
model: OPENROUTER_VISION_MODEL,
responseText: rawContent as string
});
return {
smoky: Number(jsonData.smoky) || 0,
fruity: Number(jsonData.fruity) || 0,
spicy: Number(jsonData.spicy) || 0,
sweet: Number(jsonData.sweet) || 0,
floral: Number(jsonData.floral) || 0
};
} catch (error) {
console.error('Error extracting flavor profile:', error);
return null;
}
}

View File

@@ -1,7 +1,7 @@
'use server';
import { scanLabel } from '@/app/actions/scanner';
import { analyzeBottleMistral } from './analyze-bottle-mistral';
import { analyzeBottleOpenRouter } from './analyze-bottle-openrouter';
import { searchBraveForWhiskybase } from './brave-search';
import { getAllSystemTags } from './tags';
import { supabase } from '@/lib/supabase';
@@ -17,7 +17,7 @@ export async function magicScan(input: any): Promise<AnalysisResponse & { wb_id?
return null;
};
const provider = getValue(input, 'provider') || 'gemini';
const provider = getValue(input, 'provider') || 'openrouter';
const locale = getValue(input, 'locale') || 'de';
console.log(`[magicScan] Start (Provider: ${provider}, Locale: ${locale})`);
@@ -47,8 +47,8 @@ export async function magicScan(input: any): Promise<AnalysisResponse & { wb_id?
// 1. AI Analysis
let aiResponse: any;
if (provider === 'mistral') {
aiResponse = await analyzeBottleMistral(context);
if (provider === 'mistral' || provider === 'openrouter') {
aiResponse = await analyzeBottleOpenRouter(context);
} else {
aiResponse = await scanLabel(context);
}

View File

@@ -0,0 +1,176 @@
'use server';
/**
* Service to save OCR results to the database
*/
import { createClient } from '@/lib/supabase/server';
export interface OcrLogEntry {
imageUrl?: string;
imageThumbnail?: string;
rawText: string;
detectedTexts: string[];
distillery?: string | null;
distillerySource?: 'fuzzy' | 'ai' | 'manual' | null;
bottleName?: string | null;
abv?: number | null;
age?: number | null;
vintage?: string | null;
volume?: string | null;
category?: string | null;
confidence: number;
deviceInfo?: string;
ocrMethod: string;
processingTimeMs?: number;
bottleId?: string;
}
export async function saveOcrLog(entry: OcrLogEntry): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
console.warn('[saveOcrLog] No authenticated user');
return { success: false, error: 'Not authenticated' };
}
const { data, error } = await supabase
.from('ocr_logs')
.insert({
user_id: user.id,
bottle_id: entry.bottleId || null,
image_url: entry.imageUrl || null,
image_thumbnail: entry.imageThumbnail || null,
raw_text: entry.rawText,
detected_texts: entry.detectedTexts,
distillery: entry.distillery || null,
distillery_source: entry.distillerySource || null,
bottle_name: entry.bottleName || null,
abv: entry.abv || null,
age: entry.age || null,
vintage: entry.vintage || null,
volume: entry.volume || null,
category: entry.category || null,
confidence: entry.confidence,
device_info: entry.deviceInfo || null,
ocr_method: entry.ocrMethod,
processing_time_ms: entry.processingTimeMs || null,
})
.select('id')
.single();
if (error) {
console.error('[saveOcrLog] Insert error:', error);
return { success: false, error: error.message };
}
console.log('[saveOcrLog] Saved OCR log:', data?.id);
return { success: true, id: data?.id };
} catch (err: any) {
console.error('[saveOcrLog] Error:', err);
return { success: false, error: err.message };
}
}
/**
* Get OCR logs for admin dashboard
*/
export async function getOcrLogs(limit: number = 50): Promise<{
success: boolean;
data?: any[];
error?: string;
}> {
try {
const supabase = await createClient();
const { data, error } = await supabase
.from('ocr_logs')
.select(`
*,
profiles:user_id (username)
`)
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
console.error('[getOcrLogs] Query error:', error);
return { success: false, error: error.message };
}
return { success: true, data: data || [] };
} catch (err: any) {
console.error('[getOcrLogs] Error:', err);
return { success: false, error: err.message };
}
}
/**
* Get OCR stats for admin dashboard
*/
export async function getOcrStats(): Promise<{
totalScans: number;
todayScans: number;
avgConfidence: number;
topDistilleries: { name: string; count: number }[];
}> {
try {
const supabase = await createClient();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Total scans
const { count: totalScans } = await supabase
.from('ocr_logs')
.select('*', { count: 'exact', head: true });
// Today's scans
const { count: todayScans } = await supabase
.from('ocr_logs')
.select('*', { count: 'exact', head: true })
.gte('created_at', today.toISOString());
// Average confidence
const { data: confidenceData } = await supabase
.from('ocr_logs')
.select('confidence');
const avgConfidence = confidenceData && confidenceData.length > 0
? Math.round(confidenceData.reduce((sum, d) => sum + (d.confidence || 0), 0) / confidenceData.length)
: 0;
// Top distilleries
const { data: distilleryData } = await supabase
.from('ocr_logs')
.select('distillery')
.not('distillery', 'is', null);
const distilleryCounts = new Map<string, number>();
distilleryData?.forEach(d => {
if (d.distillery) {
distilleryCounts.set(d.distillery, (distilleryCounts.get(d.distillery) || 0) + 1);
}
});
const topDistilleries = Array.from(distilleryCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
return {
totalScans: totalScans || 0,
todayScans: todayScans || 0,
avgConfidence,
topDistilleries,
};
} catch (err) {
console.error('[getOcrStats] Error:', err);
return {
totalScans: 0,
todayScans: 0,
avgConfidence: 0,
topDistilleries: [],
};
}
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache';
import { validateSession } from './validate-session';
import { TastingNoteSchema, TastingNoteData } from '@/types/whisky';
import { extractFlavorProfile } from './extract-flavor-profile';
export async function saveTasting(rawData: TastingNoteData) {
const supabase = await createClient();
@@ -14,6 +15,24 @@ export async function saveTasting(rawData: TastingNoteData) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
// Extract Tag Names for AI analysis
let tagNames: string[] = [];
if (data.tag_ids && data.tag_ids.length > 0) {
const { data: tags } = await supabase
.from('tags')
.select('name')
.in('id', data.tag_ids);
if (tags) tagNames = tags.map(t => t.name);
}
// Generate Flavor Profile via AI
const flavorProfile = await extractFlavorProfile(
data.nose_notes || '',
data.palate_notes || '',
data.finish_notes || '',
tagNames
);
// Validate Session Age (12 hour limit)
if (data.session_id) {
const isValid = await validateSession(data.session_id);
@@ -32,9 +51,16 @@ export async function saveTasting(rawData: TastingNoteData) {
nose_notes: data.nose_notes,
palate_notes: data.palate_notes,
finish_notes: data.finish_notes,
flavor_profile: flavorProfile,
is_sample: data.is_sample || false,
tasted_at: data.tasted_at || new Date().toISOString(),
created_at: new Date().toISOString(),
// New Blind Mode fields
blind_label: data.blind_label,
guess_abv: data.guess_abv,
guess_age: data.guess_age,
guess_region: data.guess_region,
guess_points: data.guess_points,
})
.select()
.single();

34
src/types/text-detector.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
/**
* TypeScript declarations for experimental Shape Detection API
* Used for native OCR via TextDetector
*/
interface DetectedText {
boundingBox: DOMRectReadOnly;
cornerPoints: { x: number; y: number }[];
rawValue: string;
}
interface TextDetector {
detect(image: ImageBitmapSource): Promise<DetectedText[]>;
}
interface TextDetectorConstructor {
new(): TextDetector;
}
declare global {
interface Window {
TextDetector?: TextDetectorConstructor;
ai?: {
languageModel?: {
create(): Promise<{
prompt(text: string): Promise<string>;
destroy(): void;
}>;
};
};
}
}
export { };

View File

@@ -1,6 +1,13 @@
import { z } from 'zod';
const coerceNumber = z.preprocess((val) => {
const coerceAbv = z.preprocess((val) => {
if (val === null || val === undefined || val === '') return null;
const n = Number(val);
if (isNaN(n)) return null;
return n;
}, z.number().min(0).max(100).nullish());
const coerceAge = z.preprocess((val) => {
if (val === null || val === undefined || val === '') return null;
const n = Number(val);
if (isNaN(n)) return null;
@@ -15,8 +22,8 @@ export const BottleMetadataSchema = z.object({
bottler: z.string().trim().max(255).nullish(),
series: z.string().trim().max(255).nullish(),
category: z.string().trim().max(100).nullish(),
abv: coerceNumber,
age: coerceNumber,
abv: coerceAbv,
age: coerceAge,
vintage: z.string().trim().max(50).nullish(),
bottleCode: z.string().trim().max(100).nullish(),
whiskybaseId: z.string().trim().max(50).nullish(),
@@ -26,6 +33,7 @@ export const BottleMetadataSchema = z.object({
cask_type: z.string().trim().max(255).nullish(),
is_whisky: z.boolean().default(true),
confidence: z.number().min(0).max(100).default(100),
confidence_scores: z.record(z.string(), z.number().min(0).max(100)).nullish(),
purchase_price: z.number().min(0).nullish(),
status: z.enum(['sealed', 'open', 'sampled', 'empty']).default('sealed').nullish(),
suggested_tags: z.array(z.string().trim().max(100)).nullish(),
@@ -45,6 +53,18 @@ export const TastingNoteSchema = z.object({
buddy_ids: z.array(z.string().uuid()).optional(),
tag_ids: z.array(z.string().uuid()).optional(),
tasted_at: z.string().datetime().optional(),
blind_label: z.string().trim().max(100).nullish(),
guess_abv: z.number().min(0).max(100).nullish(),
guess_age: z.number().min(0).max(200).nullish(),
guess_region: z.string().trim().max(100).nullish(),
guess_points: z.number().min(0).nullish(),
flavor_profile: z.object({
smoky: z.number().min(0).max(100),
fruity: z.number().min(0).max(100),
spicy: z.number().min(0).max(100),
sweet: z.number().min(0).max(100),
floral: z.number().min(0).max(100),
}).optional(),
});
export type TastingNoteData = z.infer<typeof TastingNoteSchema>;
@@ -53,8 +73,8 @@ export const UpdateBottleSchema = z.object({
name: z.string().trim().min(1).max(255).nullish(),
distillery: z.string().trim().max(255).nullish(),
category: z.string().trim().max(100).nullish(),
abv: coerceNumber,
age: coerceNumber,
abv: coerceAbv,
age: coerceAge,
whiskybase_id: z.string().trim().max(50).nullish(),
purchase_price: z.number().min(0).nullish(),
distilled_at: z.string().trim().max(50).nullish(),
@@ -93,8 +113,8 @@ export type AdminSettingsData = z.infer<typeof AdminSettingsSchema>;
export const DiscoveryDataSchema = z.object({
name: z.string().trim().min(1).max(255),
distillery: z.string().trim().max(255).nullish(),
abv: coerceNumber,
age: coerceNumber,
abv: coerceAbv,
age: coerceAge,
distilled_at: z.string().trim().max(50).nullish(),
bottled_at: z.string().trim().max(50).nullish(),
batch_info: z.string().trim().max(255).nullish(),

View File

@@ -0,0 +1,98 @@
import { pipeline, env } from '@xenova/transformers';
console.log('[BG-Removal Worker] Script loaded.');
// Configuration
env.allowLocalModels = false;
env.useBrowserCache = true;
env.allowRemoteModels = true;
let segmenter: any = null;
/**
* Singleton for the RMBG model
*/
const getSegmenter = async () => {
if (!segmenter) {
console.log('[BG-Removal Worker] Loading RMBG-1.4 model...');
try {
segmenter = await pipeline('image-segmentation', 'Xenova/RMBG-1.4', {
quantized: true,
// @ts-ignore
device: 'webgpu',
});
console.log('[BG-Removal Worker] Model loaded successfully.');
} catch (err) {
console.warn('[BG-Removal Worker] WebGPU failed, falling back to WASM:', err);
segmenter = await pipeline('image-segmentation', 'Xenova/RMBG-1.4', {
quantized: true,
});
console.log('[BG-Removal Worker] Model loaded successfully (WASM).');
}
}
return segmenter;
};
/**
* Apply the alpha mask to the original image
*/
const applyMaskWithFeathering = async (originalBlob: Blob, mask: any): Promise<Blob> => {
const bitmap = await createImageBitmap(originalBlob);
const { width, height } = bitmap;
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error("No Canvas context");
// 1. Convert 1-channel mask (Alpha) to 4-channel RGBA
const maskCanvas = new OffscreenCanvas(mask.width, mask.height);
const maskCtx = maskCanvas.getContext('2d');
const rgbaData = new Uint8ClampedArray(mask.width * mask.height * 4);
for (let i = 0; i < mask.data.length; ++i) {
const val = mask.data[i];
rgbaData[i * 4] = 0; // R
rgbaData[i * 4 + 1] = 0; // G
rgbaData[i * 4 + 2] = 0; // B
rgbaData[i * 4 + 3] = val; // A
}
const maskImageData = new ImageData(rgbaData, mask.width, mask.height);
maskCtx!.putImageData(maskImageData, 0, 0);
// 2. Composite
ctx.drawImage(maskCanvas, 0, 0, width, height);
ctx.globalCompositeOperation = 'source-in';
ctx.drawImage(bitmap, 0, 0);
return await canvas.convertToBlob({ type: 'image/png' });
};
self.onmessage = async (e) => {
const { type, id, imageBlob } = e.data;
if (type === 'ping') {
self.postMessage({ type: 'pong' });
return;
}
if (!imageBlob) return;
console.log(`[BG-Removal Worker] Received request for ${id}`);
try {
const model = await getSegmenter();
const url = URL.createObjectURL(imageBlob);
console.log('[BG-Removal Worker] Running inference...');
const output = await model(url);
console.log('[BG-Removal Worker] Applying mask...');
const processedBlob = await applyMaskWithFeathering(imageBlob, output);
self.postMessage({ id, status: 'success', blob: processedBlob });
URL.revokeObjectURL(url);
console.log(`[BG-Removal Worker] Successfully processed ${id}`);
} catch (err: any) {
console.error(`[BG-Removal Worker] processing Error (${id}):`, err);
self.postMessage({ id, status: 'error', error: err.message });
}
};