Compare commits

..

3 Commits

Author SHA1 Message Date
d109dfad0e fix: Log empty OCR results to help debug TextDetector availability
- Cascade OCR now saves a log entry even when no text is detected
- Logs ocrMethod as 'text_detector' or 'not_supported' for debugging
- Helps identify when browsers block the TextDetector API
2026-01-18 20:57:41 +01:00
9ba0825bcd 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
2026-01-18 20:38:48 +01:00
83e852e5fb Fix onboarding tutorial visibility and apply security remediation tasks (ABV sanitization, i18n hardening, regex escaping) 2026-01-06 13:19:05 +01:00
50 changed files with 3920 additions and 744 deletions

2
.semgrepignore Normal file
View File

@@ -0,0 +1,2 @@
# Ignore console.log formatting warnings
javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring

6
Logs/cpu.log Normal file
View File

@@ -0,0 +1,6 @@
1/6 22:21:38.843 vendor: AuthenticAMD
1/6 22:21:38.843 branding: AMD Ryzen 7 5700X3D 8-Core Processor
1/6 22:21:38.843 features: lahf64 cmpxchg16b sse sse2 sse3 ssse3 sse41 sse42 avx avx2 aesni clmul sha rdrand
1/6 22:21:38.843 sockets: 1
1/6 22:21:38.843 cores: 8
1/6 22:21:38.843 threads: 16

View File

@@ -0,0 +1,5 @@
-- Add flavor_profile column to tastings table
ALTER TABLE public.tastings
ADD COLUMN IF NOT EXISTS flavor_profile JSONB;
COMMENT ON COLUMN public.tastings.flavor_profile IS 'Stores radar chart scores for smoky, fruity, spicy, sweet, and floral (0-100).';

View File

@@ -18,6 +18,7 @@
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.7",
"@xenova/transformers": "^2.17.2",
"ai": "^5.0.116",
"browser-image-compression": "^2.0.2",
"canvas-confetti": "^1.9.3",

563
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
// Background Removal Worker using briaai/RMBG-1.4
// Using @huggingface/transformers v3
import { AutoModel, AutoProcessor, RawImage, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1';
console.log('[BG-Processor Worker] Script loaded from /public');
// Configuration
env.allowLocalModels = false;
env.useBrowserCache = true;
// Force WASM backend (more compatible)
env.backends.onnx.wasm.proxy = false;
let model = null;
let processor = null;
/**
* Load the RMBG-1.4 model (WASM only for compatibility)
*/
const loadModel = async () => {
if (!model) {
console.log('[BG-Processor Worker] Loading briaai/RMBG-1.4 model (WASM)...');
model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
device: 'wasm',
dtype: 'fp32',
});
processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
console.log('[BG-Processor Worker] Model loaded successfully.');
}
return { model, processor };
};
/**
* Apply the alpha mask to the original image
*/
const applyMask = async (originalBlob, maskData, width, height) => {
const bitmap = await createImageBitmap(originalBlob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error("No Canvas context");
// Draw original image
ctx.drawImage(bitmap, 0, 0);
// Get image data
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const data = imageData.data;
// Create mask canvas at model output size
const maskCanvas = new OffscreenCanvas(width, height);
const maskCtx = maskCanvas.getContext('2d');
const maskImageData = maskCtx.createImageData(width, height);
// Convert model output to grayscale image
for (let i = 0; i < maskData.length; i++) {
const val = Math.round(Math.max(0, Math.min(1, maskData[i])) * 255);
maskImageData.data[i * 4] = val;
maskImageData.data[i * 4 + 1] = val;
maskImageData.data[i * 4 + 2] = val;
maskImageData.data[i * 4 + 3] = 255;
}
maskCtx.putImageData(maskImageData, 0, 0);
// Scale mask to original size
const scaledMaskCanvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const scaledMaskCtx = scaledMaskCanvas.getContext('2d');
scaledMaskCtx.drawImage(maskCanvas, 0, 0, bitmap.width, bitmap.height);
const scaledMaskData = scaledMaskCtx.getImageData(0, 0, bitmap.width, bitmap.height);
// Apply mask as alpha
for (let i = 0; i < data.length; i += 4) {
data[i + 3] = scaledMaskData.data[i]; // Use R channel as alpha
}
ctx.putImageData(imageData, 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-Processor Worker] Received request for ${id}`);
try {
const { model, processor } = await loadModel();
// Convert blob to RawImage
const url = URL.createObjectURL(imageBlob);
const image = await RawImage.fromURL(url);
URL.revokeObjectURL(url);
console.log('[BG-Processor Worker] Running inference...');
// Process image
const { pixel_values } = await processor(image);
// Run model
const { output } = await model({ input: pixel_values });
// Get mask data - output is a Tensor
const maskData = output.data;
const [batch, channels, height, width] = output.dims;
console.log(`[BG-Processor Worker] Mask dims: ${width}x${height}`);
console.log('[BG-Processor Worker] Applying mask...');
const processedBlob = await applyMask(imageBlob, maskData, width, height);
self.postMessage({ id, status: 'success', blob: processedBlob });
console.log(`[BG-Processor Worker] Successfully processed ${id}`);
} catch (err) {
console.error(`[BG-Processor Worker] Processing Error (${id}):`, err);
self.postMessage({ id, status: 'error', error: err.message });
}
};

99
security-report.txt Normal file
View File

@@ -0,0 +1,99 @@
┌──────────────────┐
│ 15 Code Findings │
└──────────────────┘
public/sw.js
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
75┆ console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
⋮┆----------------------------------------
174┆ console.error(`[SW] Failed to fetch ${url.pathname}:`, error);
scripts/scrape-distillery-tags.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
107┆ console.error(`❌ API Error for ${name}: ${response.status}`, data.error || data);
⋮┆----------------------------------------
116┆ console.error(`⚠️ OpenRouter Error for ${name}:`, data.error.message);
⋮┆----------------------------------------
119┆ console.error(`⚠️ No content returned for ${name}. Full response:`, JSON.stringify(data,
null, 2));
⋮┆----------------------------------------
125┆ console.error(`❌ Fetch Exception for ${name}:`, error);
src/context/AuthContext.tsx
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
40┆ console.log(`[AuthContext] event: ${event}`, {
src/hooks/useScanner.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
157┆ console.log(`[useScanner] ${providerUsed} complete:`, cloudResult);
⋮┆----------------------------------------
186┆ console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error);
src/i18n/I18nContext.tsx
❯❱ javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop
Possibility of prototype polluting function detected. By adding or modifying attributes of an object
prototype, it is possible to create attributes that exist on every object, or replace critical
attributes with malicious ones. This can be problematic if the software depends on existence or non-
existence of certain attributes, or uses pre-defined attributes of object prototype (such as
hasOwnProperty, toString or valueOf). Possible mitigations might be: freezing the object prototype,
using an object without prototypes (via Object.create(null) ), blocking modifications of attributes
that resolve to object prototype, using Map instead of object.
Details: https://sg.run/w1DB
54┆ current = current[key];
src/lib/distillery-matcher.ts
❯❱ javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
RegExp() called with a `distillery` function argument, this might allow an attacker to cause a
Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main
thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on
user-controlled input, consider performing input validation or use a regex checking/sanitization
library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear
vulnerable to ReDoS.
Details: https://sg.run/gr65
154┆ const regex = new RegExp(`^${escaped}\\s*[-–—:]?\\s*`, 'i');
⋮┆----------------------------------------
161┆ const anywhereRegex = new RegExp(`\\b${escaped}\\b\\s*[-–—:]?\\s*`, 'i');
src/services/bulk-scan.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
211┆ console.error(`Analysis failed for bottle ${bottleId}:`, error);
src/services/tags.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
33┆ console.error(`Error fetching tags for ${category}:`, error);
⋮┆----------------------------------------
39┆ console.error(`Exception in getTagsByCategory for ${category}:`, err);

78
sql/create_ocr_logs.sql Normal file
View File

@@ -0,0 +1,78 @@
-- OCR Logs Table for storing cascade OCR results
-- This allows admins to view OCR recognition results from mobile devices
CREATE TABLE IF NOT EXISTS ocr_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
bottle_id UUID REFERENCES bottles(id) ON DELETE SET NULL,
-- Image data
image_url TEXT, -- URL to the scanned image
image_thumbnail TEXT, -- Base64 thumbnail for quick preview
-- Detected fields
raw_text TEXT, -- All detected text joined
detected_texts JSONB, -- Array of individual text detections
-- Extracted data
distillery TEXT,
distillery_source TEXT, -- 'fuzzy', 'ai', 'manual'
bottle_name TEXT,
abv DECIMAL(5,2),
age INTEGER,
vintage TEXT,
volume TEXT,
category TEXT,
-- Meta
confidence INTEGER, -- 0-100
device_info TEXT, -- User agent or device type
ocr_method TEXT, -- 'text_detector', 'fallback', etc.
processing_time_ms INTEGER,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Index for efficient queries
CREATE INDEX IF NOT EXISTS idx_ocr_logs_user_id ON ocr_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_ocr_logs_created_at ON ocr_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ocr_logs_distillery ON ocr_logs(distillery);
-- RLS Policies
ALTER TABLE ocr_logs ENABLE ROW LEVEL SECURITY;
-- Users can view their own logs
CREATE POLICY "Users can view own ocr_logs"
ON ocr_logs FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own logs
CREATE POLICY "Users can insert own ocr_logs"
ON ocr_logs FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Admins can view all logs
CREATE POLICY "Admins can view all ocr_logs"
ON ocr_logs FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_ocr_logs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_ocr_logs_updated_at
BEFORE UPDATE ON ocr_logs
FOR EACH ROW
EXECUTE FUNCTION update_ocr_logs_updated_at();

View File

@@ -0,0 +1,36 @@
-- Add Blind Tasting support to Sessions
ALTER TABLE public.tasting_sessions
ADD COLUMN IF NOT EXISTS is_blind BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_revealed BOOLEAN DEFAULT false;
-- Add Guessing fields to Tastings
ALTER TABLE public.tastings
ADD COLUMN IF NOT EXISTS blind_label TEXT,
ADD COLUMN IF NOT EXISTS guess_abv DECIMAL,
ADD COLUMN IF NOT EXISTS guess_age INTEGER,
ADD COLUMN IF NOT EXISTS guess_region TEXT,
ADD COLUMN IF NOT EXISTS guess_points INTEGER;
-- Update RLS Policies for blind sessions
-- Guests should only see bottle details if NOT blind OR revealed
-- This is a complex policy update, we'll refine the existing tastings_select_policy
DROP POLICY IF EXISTS "tastings_select_policy" ON public.tastings;
CREATE POLICY "tastings_select_policy" ON public.tastings FOR SELECT USING (
-- You can see your own tastings
auth.uid() = user_id
OR
-- You can see tastings in a session you participate in
EXISTS (
SELECT 1
FROM public.session_participants sp
JOIN public.buddies b ON b.id = sp.buddy_id
WHERE sp.session_id = public.tastings.session_id
AND b.buddy_profile_id = auth.uid()
)
);
-- Note: The logic for hiding bottle details will be handled in the UI/API layer
-- as the RLS here still needs to allow access to the tasting record itself.
-- Hiding 'bottle_id' content for blind tastings will be done in the frontend
-- based on session.is_blind and session.is_revealed.

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

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
@@ -50,12 +51,22 @@ const getSteps = (t: (path: string) => string): OnboardingStep[] => [
export default function OnboardingTutorial() {
const { t } = useI18n();
const { user, isLoading } = useAuth();
const STEPS = getSteps(t);
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const pathname = usePathname();
useEffect(() => {
// Don't show if auth is still loading
if (isLoading) return;
// Don't show if no user is logged in
if (!user) {
setIsOpen(false);
return;
}
// Don't show on login/auth pages
if (pathname === '/login' || pathname === '/auth' || pathname === '/register') {
return;

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

@@ -43,8 +43,12 @@ export const I18nProvider = ({ children }: { children: ReactNode }) => {
let current: any = translations[locale];
for (const key of keys) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
console.warn(`Blocked potentially malicious translation key: ${key}`);
return path;
}
if (current[key] === undefined) {
console.warn(`Translation missing for key: ${path} in locale: ${locale}`);
console.warn(`Translation missing for key: ${key} in path: ${path} in locale: ${locale}`);
return path;
}
current = current[key];

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

@@ -30,6 +30,13 @@ const fuse = new Fuse<Distillery>(distilleries as Distillery[], {
minMatchCharLength: 4,
});
/**
* Escapes special characters for use in a regular expression
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Preprocess raw distillery name for better matching
*/
@@ -38,7 +45,7 @@ function preprocessName(raw: string): string {
// Remove stopwords
for (const word of STOPWORDS) {
clean = clean.replace(new RegExp(`\\b${word}\\b`, 'gi'), ' ');
clean = clean.replace(new RegExp(`\\b${escapeRegExp(word)}\\b`, 'gi'), ' ');
}
// Remove extra whitespace
@@ -143,7 +150,7 @@ export function cleanBottleName(bottleName: string, distillery: string): string
}
// Create regex to match distillery at start of name (case-insensitive)
const escaped = distillery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escaped = escapeRegExp(distillery);
const regex = new RegExp(`^${escaped}\\s*[-–—:]?\\s*`, 'i');
let cleaned = bottleName.replace(regex, '').trim();

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,13 @@ 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') {
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
}
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
@@ -134,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')
@@ -160,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 {
@@ -181,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.',
};
}
}

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

@@ -0,0 +1,402 @@
'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');
// Still save to DB for debugging (shows TextDetector availability)
const processingTimeMs = performance.now() - startTime;
try {
await saveOcrLog({
rawText: '',
detectedTexts: [],
confidence: 0,
deviceInfo: navigator.userAgent,
ocrMethod: 'TextDetector' in window ? 'text_detector' : 'not_supported',
processingTimeMs: Math.round(processingTimeMs),
});
console.log('[CascadeOCR] Empty result logged to DB');
} catch (err) {
console.warn('[CascadeOCR] Failed to log empty result:', err);
}
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 });
}
};

File diff suppressed because one or more lines are too long