Compare commits
3 Commits
68ac857091
...
d109dfad0e
| Author | SHA1 | Date | |
|---|---|---|---|
| d109dfad0e | |||
| 9ba0825bcd | |||
| 83e852e5fb |
2
.semgrepignore
Normal file
2
.semgrepignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Ignore console.log formatting warnings
|
||||
javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||
6
Logs/cpu.log
Normal file
6
Logs/cpu.log
Normal 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
|
||||
5
add_flavor_radar_to_tastings.sql
Normal file
5
add_flavor_radar_to_tastings.sql
Normal 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).';
|
||||
@@ -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
563
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
123
public/bg-processor.worker.js
Normal file
123
public/bg-processor.worker.js
Normal 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
99
security-report.txt
Normal 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
78
sql/create_ocr_logs.sql
Normal 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();
|
||||
36
sql/migrate_blind_tasting.sql
Normal file
36
sql/migrate_blind_tasting.sql
Normal 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.
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
255
src/app/admin/ocr-logs/page.tsx
Normal file
255
src/app/admin/ocr-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
66
src/components/BackgroundRemovalHandler.tsx
Normal file
66
src/components/BackgroundRemovalHandler.tsx
Normal 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
|
||||
}
|
||||
@@ -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} />} />
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
65
src/components/FlavorRadar.tsx
Normal file
65
src/components/FlavorRadar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
src/components/NativeOCRScanner.tsx
Normal file
277
src/components/NativeOCRScanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
13
src/config/features.ts
Normal 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,
|
||||
};
|
||||
152
src/hooks/useImageProcessor.ts
Normal file
152
src/hooks/useImageProcessor.ts
Normal 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
196
src/hooks/useScanFlow.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
402
src/services/cascade-ocr.ts
Normal 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 };
|
||||
}
|
||||
76
src/services/extract-flavor-profile.ts
Normal file
76
src/services/extract-flavor-profile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
176
src/services/save-ocr-log.ts
Normal file
176
src/services/save-ocr-log.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
34
src/types/text-detector.d.ts
vendored
Normal 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 { };
|
||||
@@ -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(),
|
||||
|
||||
98
src/workers/bg-processor.worker.ts
Normal file
98
src/workers/bg-processor.worker.ts
Normal 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
Reference in New Issue
Block a user