feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector
This commit is contained in:
103
.aiideas
103
.aiideas
@@ -1,28 +1,85 @@
|
||||
Act as a Senior TypeScript/Next.js Developer.
|
||||
´
|
||||
|
||||
I need a robust client-side image processing utility (an "Image Agent") to optimize user uploads before sending them to an LLM or Supabase.
|
||||
Act as a Senior Next.js Developer.
|
||||
|
||||
**Task:**
|
||||
Create a utility file `src/utils/image-processing.ts`.
|
||||
This file should export a function `processImageForAI` that uses the library `browser-image-compression`.
|
||||
Refactor the whisky analysis logic to optimize for perceived performance using a "Optimistic UI" approach.
|
||||
Split the current `analyzeBottle` logic into **two separate Server Actions**.
|
||||
|
||||
**Requirements:**
|
||||
1. **Input:** The function takes a raw `File` object (from an HTML input).
|
||||
2. **Processing Logic:**
|
||||
- Resize the image to a maximum of **1024x1024** pixels (maintain aspect ratio).
|
||||
- Convert the image to **WebP** format.
|
||||
- Limit the file size to approx **0.4MB**.
|
||||
- Enable `useWebWorker: true` to prevent UI freezing.
|
||||
3. **Output:** The function must return a Promise that resolves to an object with this interface:
|
||||
```typescript
|
||||
interface ProcessedImage {
|
||||
file: File; // The compressed WebP file (ready for Supabase storage)
|
||||
base64: string; // The Base64 string (ready for LLM API calls)
|
||||
originalFile: File; // Pass through the original file
|
||||
### 1. Create `src/app/actions/scan-label.ts` (Fast OCR)
|
||||
This action handles the image upload via `FormData`.
|
||||
It must use the model with `safetySettings: BLOCK_NONE`.
|
||||
It must **NOT** generate flavor tags or search strings.
|
||||
|
||||
**System Prompt for this Action:**
|
||||
```text
|
||||
ROLE: High-Precision OCR Engine for Whisky Labels.
|
||||
OBJECTIVE: Extract visible metadata strictly from the image.
|
||||
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
|
||||
|
||||
TASK:
|
||||
1. Identify if the image contains a whisky/spirit bottle.
|
||||
2. Extract the following technical details into the JSON schema below.
|
||||
3. If a value is not visible or cannot be inferred with high certainty, use null.
|
||||
|
||||
EXTRACTION RULES:
|
||||
- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
|
||||
- Distillery: The producer of the spirit.
|
||||
- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
|
||||
- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
|
||||
- Codes: Look for laser codes etched on glass/label (e.g., "L20394...").
|
||||
- Dates: Distinguish clearly between Vintage (distilled year), Bottled year, and Age.
|
||||
|
||||
OUTPUT SCHEMA (Strict JSON):
|
||||
{
|
||||
"name": "string",
|
||||
"distillery": "string",
|
||||
"bottler": "stringOrNull",
|
||||
"category": "string (e.g. Single Malt Scotch Whisky)",
|
||||
"abv": numberOrNull,
|
||||
"age": numberOrNull,
|
||||
"vintage": "stringOrNull",
|
||||
"distilled_at": "stringOrNull (Year/Date)",
|
||||
"bottled_at": "stringOrNull (Year/Date)",
|
||||
"batch_info": "stringOrNull",
|
||||
"bottleCode": "stringOrNull",
|
||||
"whiskybaseId": "stringOrNull",
|
||||
"is_whisky": boolean,
|
||||
"confidence": number
|
||||
}
|
||||
```
|
||||
4. **Helper:** Include a helper function to convert the resulting Blob/File to a Base64 string correctly.
|
||||
5. **Edge Cases:** Handle errors gracefully (try/catch) and ensure the returned `file` has the correct `.webp` extension and mime type.
|
||||
|
||||
**Step 1:** Give me the `npm install` command to add the necessary library.
|
||||
**Step 2:** Write the complete `src/utils/image-processing.ts` code with proper JSDoc comments.
|
||||
2. Create src/app/actions/enrich-data.ts (Magic/Tags)
|
||||
|
||||
This action takes name and distillery (strings) as input. No image upload. It uses gemini-1.5-flash to retrieve knowledge-based data.
|
||||
|
||||
System Prompt for this Action:
|
||||
Plaintext
|
||||
|
||||
TASK: You are a Whisky Sommelier.
|
||||
INPUT: A whisky named "${name}" from distillery "${distillery}".
|
||||
|
||||
1. DATABASE LOOKUP:
|
||||
Retrieve the sensory profile and specific Whiskybase search string for this bottling.
|
||||
|
||||
2. TAGGING:
|
||||
Select the top 5-8 flavor tags strictly from this list:
|
||||
[${availableTags}]
|
||||
|
||||
3. SEARCH STRING:
|
||||
Create a precise search string for Whiskybase using: "site:whiskybase.com [Distillery] [Vintage/Age] [Bottler/Edition]"
|
||||
|
||||
OUTPUT JSON:
|
||||
{
|
||||
"suggested_tags": ["tag1", "tag2", "tag3"],
|
||||
"suggested_custom_tags": ["unique_note_if_missing_in_list"],
|
||||
"search_string": "string"
|
||||
}
|
||||
|
||||
3. Integration
|
||||
|
||||
Update the frontend component to:
|
||||
|
||||
Call scan-label first.
|
||||
|
||||
Update the UI state with the metadata immediately.
|
||||
|
||||
If the scan was successful, automatically trigger enrich-data in the background to fetch tags and search string.
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^2.0.51",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@mistralai/mistralai": "^1.11.0",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"ai": "^5.0.116",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"dexie": "^4.2.1",
|
||||
@@ -31,7 +33,8 @@
|
||||
"recharts": "^3.6.0",
|
||||
"sharp": "^0.34.5",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
|
||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@ai-sdk/google':
|
||||
specifier: ^2.0.51
|
||||
version: 2.0.51(zod@3.25.76)
|
||||
'@google/generative-ai':
|
||||
specifier: ^0.24.1
|
||||
version: 0.24.1
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.7
|
||||
version: 5.90.12(react@19.2.3)
|
||||
ai:
|
||||
specifier: ^5.0.116
|
||||
version: 5.0.116(zod@3.25.76)
|
||||
browser-image-compression:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
@@ -46,7 +52,7 @@ importers:
|
||||
version: 0.468.0(react@19.2.3)
|
||||
next:
|
||||
specifier: 16.1.0
|
||||
version: 16.1.0(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
openai:
|
||||
specifier: ^6.15.0
|
||||
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
|
||||
@@ -68,6 +74,9 @@ importers:
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
zod-to-json-schema:
|
||||
specifier: ^3.25.0
|
||||
version: 3.25.0(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.57.0
|
||||
@@ -116,7 +125,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -126,6 +135,28 @@ packages:
|
||||
'@adobe/css-tools@4.4.4':
|
||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||
|
||||
'@ai-sdk/gateway@2.0.23':
|
||||
resolution: {integrity: sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/google@2.0.51':
|
||||
resolution: {integrity: sha512-5VMHdZTP4th00hthmh98jP+BZmxiTRMB9R2qh/AuF6OkQeiJikqxZg3hrWDfYrCmQ12wDjy6CbIypnhlwZiYrg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.19':
|
||||
resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider@2.0.0':
|
||||
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -687,6 +718,10 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@playwright/test@1.57.0':
|
||||
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1128,6 +1163,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vercel/oidc@3.0.5':
|
||||
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vitejs/plugin-react@5.1.2':
|
||||
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1177,6 +1216,12 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ai@5.0.116:
|
||||
resolution: {integrity: sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
|
||||
@@ -1713,6 +1758,10 @@ packages:
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
eventsource-parser@3.0.6:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -2115,6 +2164,9 @@ packages:
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@@ -3039,6 +3091,30 @@ snapshots:
|
||||
|
||||
'@adobe/css-tools@4.4.4': {}
|
||||
|
||||
'@ai-sdk/gateway@2.0.23(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
|
||||
'@vercel/oidc': 3.0.5
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/google@2.0.51(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.19(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider@2.0.0':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.1':
|
||||
@@ -3497,6 +3573,8 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@playwright/test@1.57.0':
|
||||
dependencies:
|
||||
playwright: 1.57.0
|
||||
@@ -3911,6 +3989,8 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vercel/oidc@3.0.5': {}
|
||||
|
||||
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -3970,6 +4050,14 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ai@5.0.116(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 2.0.23(zod@3.25.76)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ajv@6.12.6:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -4690,6 +4778,8 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -5103,6 +5193,8 @@ snapshots:
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@1.0.2:
|
||||
@@ -5206,7 +5298,7 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next@16.1.0(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.1.0
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -5225,6 +5317,7 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.1.0
|
||||
'@next/swc-win32-arm64-msvc': 16.1.0
|
||||
'@next/swc-win32-x64-msvc': 16.1.0
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@playwright/test': 1.57.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
@@ -5990,7 +6083,7 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
|
||||
vitest@4.0.16(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))
|
||||
@@ -6013,6 +6106,7 @@ snapshots:
|
||||
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 20.19.27
|
||||
jsdom: 27.3.0
|
||||
transitivePeerDependencies:
|
||||
|
||||
9
public/lib/browser-image-compression.js
Normal file
9
public/lib/browser-image-compression.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'whisky-vault-v13-offline';
|
||||
const CACHE_NAME = 'whisky-vault-v14-offline';
|
||||
|
||||
// CONFIG: Assets
|
||||
const STATIC_ASSETS = [
|
||||
@@ -6,6 +6,7 @@ const STATIC_ASSETS = [
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/favicon.ico',
|
||||
'/lib/browser-image-compression.js',
|
||||
];
|
||||
|
||||
const CORE_PAGES = [
|
||||
|
||||
26
scripts/add-credits.sql
Normal file
26
scripts/add-credits.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Quick script to add credits for development/testing
|
||||
-- Run this in Supabase SQL Editor
|
||||
|
||||
-- Add 1000 credits to all users (for development)
|
||||
UPDATE user_credits
|
||||
SET balance = balance + 1000,
|
||||
updated_at = NOW()
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM auth.users
|
||||
);
|
||||
|
||||
-- Or add credits to a specific user by email:
|
||||
-- UPDATE user_credits
|
||||
-- SET balance = balance + 1000,
|
||||
-- updated_at = NOW()
|
||||
-- WHERE user_id = (
|
||||
-- SELECT id FROM auth.users WHERE email = 'your-email@example.com'
|
||||
-- );
|
||||
|
||||
SELECT
|
||||
u.email,
|
||||
uc.balance,
|
||||
uc.updated_at
|
||||
FROM user_credits uc
|
||||
JOIN auth.users u ON u.id = uc.user_id
|
||||
ORDER BY uc.updated_at DESC;
|
||||
117
src/app/actions/enrich-data.ts
Normal file
117
src/app/actions/enrich-data.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
'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';
|
||||
import { getAllSystemTags } from '@/services/tags';
|
||||
|
||||
// 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: [],
|
||||
};
|
||||
|
||||
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
||||
}
|
||||
|
||||
let supabase;
|
||||
try {
|
||||
let tagsToUse = availableTags;
|
||||
if (!tagsToUse) {
|
||||
const systemTags = await getAllSystemTags();
|
||||
tagsToUse = systemTags.map(t => t.name).join(', ');
|
||||
}
|
||||
|
||||
supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert.' };
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// Initialize Gemini with native schema validation
|
||||
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 instruction = `Identify the sensory profile for the following whisky.
|
||||
Whisky: ${name} (${distillery})
|
||||
Language: ${language}
|
||||
Available system tags (pick relevant ones): ${tagsToUse}
|
||||
|
||||
Instructions:
|
||||
1. Select the most appropriate sensory tags from the "Available system tags" list.
|
||||
2. If there are dominant notes that are NOT in the system list, add them to "suggested_custom_tags".
|
||||
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
|
||||
|
||||
const startApi = performance.now();
|
||||
const result = await model.generateContent(instruction);
|
||||
const endApi = performance.now();
|
||||
|
||||
const responseText = result.response.text();
|
||||
console.log('[EnrichData] Raw Response:', responseText);
|
||||
const jsonData = JSON.parse(responseText);
|
||||
|
||||
// Track usage
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'enrichData',
|
||||
success: true
|
||||
});
|
||||
|
||||
await deductCredits(userId, 'gemini_ai', 'Data enrichment');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: jsonData,
|
||||
perf: {
|
||||
apiDuration: endApi - startApi
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Enrich Data Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der Daten-Anreicherung.',
|
||||
};
|
||||
}
|
||||
}
|
||||
207
src/app/actions/scan-label.ts
Normal file
207
src/app/actions/scan-label.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
'use server';
|
||||
|
||||
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
||||
import { BottleMetadataSchema, AnalysisResponse } from '@/types/whisky';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createHash } from 'crypto';
|
||||
import { trackApiUsage } from '@/services/track-api-usage';
|
||||
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
||||
|
||||
// Native Schema Definition for Gemini API
|
||||
const metadataSchema = {
|
||||
description: "Technical metadata extracted from whisky label",
|
||||
type: SchemaType.OBJECT as const,
|
||||
properties: {
|
||||
name: { type: SchemaType.STRING, description: "Full whisky name including vintage/age", nullable: false },
|
||||
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
|
||||
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
|
||||
category: { type: SchemaType.STRING, description: "Whisky category (e.g., Single Malt, Blended)", 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 year", 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 information", nullable: true },
|
||||
bottleCode: { type: SchemaType.STRING, description: "Bottle code or serial 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"],
|
||||
};
|
||||
|
||||
export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
||||
}
|
||||
|
||||
let supabase;
|
||||
try {
|
||||
const getValue = (obj: any, key: string): any => {
|
||||
if (obj && typeof obj.get === 'function') return obj.get(key);
|
||||
if (obj && typeof obj[key] !== 'undefined') return obj[key];
|
||||
return null;
|
||||
};
|
||||
|
||||
const file = getValue(input, 'file') as File;
|
||||
if (!file) {
|
||||
return { success: false, error: 'Kein Bild empfangen.' };
|
||||
}
|
||||
|
||||
supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert.' };
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||
|
||||
if (!creditCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
|
||||
};
|
||||
}
|
||||
|
||||
const perfTotal = performance.now();
|
||||
|
||||
// Step 1: Image Preparation
|
||||
const startImagePrep = performance.now();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
||||
const endImagePrep = performance.now();
|
||||
|
||||
// Step 2: Cache Check
|
||||
const startCacheCheck = performance.now();
|
||||
const { data: cachedResult } = await supabase
|
||||
.from('vision_cache')
|
||||
.select('result')
|
||||
.eq('hash', imageHash)
|
||||
.maybeSingle();
|
||||
const endCacheCheck = performance.now();
|
||||
|
||||
if (cachedResult) {
|
||||
return {
|
||||
success: true,
|
||||
data: cachedResult.result as any,
|
||||
perf: {
|
||||
imagePrep: endImagePrep - startImagePrep,
|
||||
cacheCheck: endCacheCheck - startCacheCheck,
|
||||
apiCall: 0,
|
||||
parsing: 0,
|
||||
validation: 0,
|
||||
dbOps: 0,
|
||||
uploadSize: buffer.length,
|
||||
total: performance.now() - perfTotal,
|
||||
cacheHit: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Base64 Encoding
|
||||
const startEncoding = performance.now();
|
||||
const base64Data = buffer.toString('base64');
|
||||
const mimeType = file.type || 'image/webp';
|
||||
const uploadSize = buffer.length;
|
||||
const endEncoding = performance.now();
|
||||
|
||||
// Step 4: Model Initialization & Step 5: API Call
|
||||
const startAiTotal = performance.now();
|
||||
let jsonData;
|
||||
let validatedData;
|
||||
|
||||
try {
|
||||
const startModelInit = performance.now();
|
||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-2.5-flash',
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: metadataSchema as any,
|
||||
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 endModelInit = performance.now();
|
||||
|
||||
const instruction = "Extract whisky label metadata.";
|
||||
const startApi = performance.now();
|
||||
const result = await model.generateContent([
|
||||
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
||||
{ text: instruction },
|
||||
]);
|
||||
const endApi = performance.now();
|
||||
|
||||
const startParse = performance.now();
|
||||
jsonData = JSON.parse(result.response.text());
|
||||
const endParse = performance.now();
|
||||
|
||||
const startValidation = performance.now();
|
||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endValidation = performance.now();
|
||||
|
||||
// Cache record
|
||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel',
|
||||
success: true
|
||||
});
|
||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan');
|
||||
|
||||
const totalTime = performance.now() - perfTotal;
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
perf: {
|
||||
imagePrep: endImagePrep - startImagePrep,
|
||||
cacheCheck: endCacheCheck - startCacheCheck,
|
||||
encoding: endEncoding - startEncoding,
|
||||
modelInit: endModelInit - startModelInit,
|
||||
apiCall: endApi - startApi,
|
||||
parsing: endParse - startParse,
|
||||
validation: endValidation - startValidation,
|
||||
total: totalTime,
|
||||
cacheHit: false
|
||||
},
|
||||
raw: jsonData
|
||||
} as any;
|
||||
|
||||
} catch (aiError: any) {
|
||||
console.warn('[ScanLabel] AI Analysis failed, providing fallback path:', aiError.message);
|
||||
|
||||
// Track failure
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel',
|
||||
success: false,
|
||||
errorMessage: aiError.message
|
||||
});
|
||||
|
||||
// Return a specific structure that ScanAndTasteFlow can use to fallback to placeholder
|
||||
return {
|
||||
success: false,
|
||||
isAiError: true,
|
||||
error: aiError.message,
|
||||
imageHash: imageHash // Useful for local tracking
|
||||
} as any;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Scan Label Global Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der Label-Analyse.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ export async function POST(req: Request) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Check session
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const userId = user.id;
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<html lang="de" suppressHydrationWarning={true}>
|
||||
<body className={`${inter.variable} font-sans`}>
|
||||
<I18nProvider>
|
||||
<SessionProvider>
|
||||
|
||||
@@ -120,7 +120,6 @@ export default function Home() {
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase fetch error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -143,8 +142,20 @@ export default function Home() {
|
||||
|
||||
setBottles(processedBottles);
|
||||
} catch (err: any) {
|
||||
// Silently skip if offline
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
err.message?.includes('Failed to fetch') ||
|
||||
err.message?.includes('NetworkError') ||
|
||||
err.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
||||
(err && Object.keys(err).length === 0); // Empty error object from Supabase when offline
|
||||
|
||||
if (isNetworkError) {
|
||||
console.log('[fetchCollection] Skipping due to offline mode');
|
||||
setFetchError(null);
|
||||
} else {
|
||||
console.error('Detailed fetch error:', err);
|
||||
setFetchError(err.message || JSON.stringify(err));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -271,6 +282,7 @@ export default function Home() {
|
||||
isOpen={isFlowOpen}
|
||||
onClose={() => setIsFlowOpen(false)}
|
||||
imageFile={capturedFile}
|
||||
onBottleSaved={() => fetchCollection()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User, Clock } from 'lucide-react';
|
||||
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
@@ -8,7 +8,6 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { db } from '@/lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
||||
import { validateSession } from '@/services/validate-session';
|
||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||
@@ -17,8 +16,10 @@ import Link from 'next/link';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { shortenCategory } from '@/lib/format';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
import { processImageForAI } from '@/utils/image-processing';
|
||||
|
||||
interface CameraCaptureProps {
|
||||
onImageCaptured?: (base64Image: string) => void;
|
||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||
@@ -32,14 +33,12 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
const searchParams = useSearchParams();
|
||||
const { activeSession } = useSession();
|
||||
|
||||
// Maintain sessionId from query param for backwards compatibility,
|
||||
// but prefer global activeSession
|
||||
const sessionIdFromUrl = searchParams.get('session_id');
|
||||
const effectiveSessionId = activeSession?.id || sessionIdFromUrl;
|
||||
|
||||
const [validatedSessionId, setValidatedSessionId] = React.useState<string | null>(null);
|
||||
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
if (effectiveSessionId) {
|
||||
const isValid = await validateSession(effectiveSessionId);
|
||||
@@ -67,14 +66,25 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
||||
|
||||
// Performance Tracking (Admin only)
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
compression: number;
|
||||
ai: number;
|
||||
aiApi: number;
|
||||
aiParse: number;
|
||||
uploadSize: number;
|
||||
prep: number;
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -85,10 +95,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('[CameraCapture] Admin check error:', error);
|
||||
}
|
||||
console.log('[CameraCapture] Admin status:', !!data);
|
||||
if (error) console.error('[CameraCapture] Admin check error:', error);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -111,122 +118,101 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
try {
|
||||
let fileToProcess = file;
|
||||
|
||||
// HEIC / HEIF Check
|
||||
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' || file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||
|
||||
if (isHeic) {
|
||||
console.log('HEIC detected, converting...');
|
||||
const heic2any = (await import('heic2any')).default;
|
||||
const convertedBlob = await heic2any({
|
||||
blob: file,
|
||||
toType: 'image/jpeg',
|
||||
quality: 0.8
|
||||
});
|
||||
|
||||
const convertedBlob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 });
|
||||
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
setOriginalFile(fileToProcess);
|
||||
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(fileToProcess);
|
||||
const endComp = performance.now();
|
||||
|
||||
const compressedBase64 = processed.base64;
|
||||
setPreviewUrl(compressedBase64);
|
||||
setPreviewUrl(processed.base64);
|
||||
if (onImageCaptured) onImageCaptured(processed.base64);
|
||||
|
||||
if (onImageCaptured) {
|
||||
onImageCaptured(compressedBase64);
|
||||
}
|
||||
|
||||
// Check if Offline
|
||||
if (!navigator.onLine) {
|
||||
console.log('Offline detected. Queuing image...');
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processed.base64)
|
||||
.first();
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[CameraCapture] Existing pending scan found, skipping dual add');
|
||||
} else {
|
||||
await db.pending_scans.add({
|
||||
temp_id: crypto.randomUUID(),
|
||||
imageBase64: compressedBase64,
|
||||
imageBase64: processed.base64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider,
|
||||
locale: locale
|
||||
});
|
||||
}
|
||||
setIsQueued(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', processed.file);
|
||||
formData.append('provider', aiProvider);
|
||||
formData.append('locale', locale);
|
||||
|
||||
const startAi = performance.now();
|
||||
const response = await magicScan(formData);
|
||||
const response = await scanLabel(formData);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (response.success && response.data) {
|
||||
setAnalysisResult(response.data);
|
||||
|
||||
if (response.wb_id) {
|
||||
setWbDiscovery({
|
||||
id: response.wb_id,
|
||||
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
|
||||
title: `${response.data.distillery || ''} ${response.data.name || ''}`
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate Check
|
||||
const match = await findMatchingBottle(response.data);
|
||||
if (match) {
|
||||
setMatchingBottle(match);
|
||||
}
|
||||
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(response.data);
|
||||
}
|
||||
if (match) setMatchingBottle(match);
|
||||
if (onAnalysisComplete) onAnalysisComplete(response.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
|
||||
if (isAdmin) {
|
||||
if (isAdmin && response.perf) {
|
||||
setPerfMetrics({
|
||||
compression: endComp - startComp,
|
||||
ai: endAi - startAi,
|
||||
prep: endPrep - startPrep
|
||||
aiApi: response.perf.apiCall || response.perf.apiDuration || 0,
|
||||
aiParse: response.perf.parsing || response.perf.parseDuration || 0,
|
||||
uploadSize: response.perf.uploadSize || 0,
|
||||
prep: endPrep - startPrep,
|
||||
imagePrep: response.perf.imagePrep,
|
||||
cacheCheck: response.perf.cacheCheck,
|
||||
encoding: response.perf.encoding,
|
||||
modelInit: response.perf.modelInit,
|
||||
validation: response.perf.validation,
|
||||
dbOps: response.perf.dbOps,
|
||||
total: response.perf.total,
|
||||
cacheHit: response.perf.cacheHit
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If scan fails but it looks like a network issue, offer to queue
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
response.error?.toLowerCase().includes('fetch') ||
|
||||
response.error?.toLowerCase().includes('network') ||
|
||||
response.error?.toLowerCase().includes('timeout');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.log('Network issue detected during scan. Queuing...');
|
||||
await db.pending_scans.add({
|
||||
temp_id: crypto.randomUUID(),
|
||||
imageBase64: compressedBase64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider,
|
||||
locale: locale
|
||||
if (response.data.is_whisky && response.data.name && response.data.distillery) {
|
||||
enrichData(response.data.name, response.data.distillery, undefined, locale)
|
||||
.then(enrichResult => {
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
setAnalysisResult(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
search_string: enrichResult.data.search_string
|
||||
};
|
||||
});
|
||||
setIsQueued(true);
|
||||
setError(null); // Clear error as we are queuing
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[CameraCapture] Enrichment failed:', err));
|
||||
}
|
||||
} else {
|
||||
setError(response.error || t('camera.analysisError'));
|
||||
throw new Error(t('camera.analysisError'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Processing failed:', err);
|
||||
// Even on generic error, if we have a compressed image, consider queuing if it looks like connection
|
||||
if (previewUrl && !analysisResult) {
|
||||
setError(t('camera.processingError') + " - " + t('camera.offlineNotice'));
|
||||
} else {
|
||||
setError(t('camera.processingError'));
|
||||
}
|
||||
setError(err.message || t('camera.processingError'));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -234,28 +220,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleQuickSave = async () => {
|
||||
if (!analysisResult || !previewUrl) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
|
||||
if (!user) throw new Error(t('camera.authRequired'));
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
router.push(url);
|
||||
} else {
|
||||
setError(response.error || t('common.error'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Quick save failed:', err);
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('common.error'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -263,28 +241,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!analysisResult || !previewUrl) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error(t('camera.authRequired'));
|
||||
}
|
||||
|
||||
|
||||
if (!user) throw new Error(t('camera.authRequired'));
|
||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setLastSavedId(response.data.id);
|
||||
if (onSaveComplete) onSaveComplete();
|
||||
} else {
|
||||
setError(response.error || t('common.error'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('common.error'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -299,7 +269,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
abv: analysisResult.abv || undefined,
|
||||
age: analysisResult.age || undefined
|
||||
});
|
||||
|
||||
if (result.success && result.id) {
|
||||
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
|
||||
}
|
||||
@@ -308,30 +277,18 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
|
||||
const handleLinkWb = async () => {
|
||||
if (!lastSavedId || !wbDiscovery) return;
|
||||
const res = await updateBottle(lastSavedId, {
|
||||
whiskybase_id: wbDiscovery.id
|
||||
});
|
||||
if (res.success) {
|
||||
setWbDiscovery(null);
|
||||
// Show some success feedback if needed, but the button will disappear anyway
|
||||
}
|
||||
const res = await updateBottle(lastSavedId, { whiskybase_id: wbDiscovery.id });
|
||||
if (res.success) setWbDiscovery(null);
|
||||
};
|
||||
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const triggerGallery = () => {
|
||||
galleryInputRef.current?.click();
|
||||
};
|
||||
const triggerUpload = () => fileInputRef.current?.click();
|
||||
const triggerGallery = () => galleryInputRef.current?.click();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-800 transition-all hover:shadow-orange-950/20">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
||||
<button
|
||||
@@ -414,22 +371,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
<input type="file" accept="image/*" capture="environment" ref={fileInputRef} onChange={handleCapture} className="hidden" />
|
||||
<input type="file" accept="image/*" ref={galleryInputRef} onChange={handleCapture} className="hidden" />
|
||||
|
||||
{lastSavedId ? (
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
@@ -437,7 +380,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
<CheckCircle2 size={24} className="text-green-500" />
|
||||
{t('camera.saveSuccess')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||
@@ -448,258 +390,145 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{t('camera.tastingNow')}
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
|
||||
{!wbDiscovery && !isDiscovering && (
|
||||
<button
|
||||
onClick={handleDiscoverWb}
|
||||
className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm"
|
||||
>
|
||||
<button onClick={handleDiscoverWb} className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm">
|
||||
<Search size={16} />
|
||||
{t('camera.whiskybaseSearch')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isDiscovering && (
|
||||
<div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('camera.searchingWb')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wbDiscovery && (
|
||||
<div className="p-4 bg-zinc-950 border border-orange-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-orange-600">
|
||||
<Sparkles size={12} /> {t('camera.wbMatchFound')}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">
|
||||
{wbDiscovery.title}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">{wbDiscovery.title}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleLinkWb}
|
||||
className="flex-1 py-2.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
{t('common.link')}
|
||||
</button>
|
||||
<a
|
||||
href={wbDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<button onClick={handleLinkWb} className="flex-1 py-2.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors">{t('common.link')}</button>
|
||||
<a href={wbDiscovery.url} target="_blank" rel="noopener noreferrer" className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1">
|
||||
<ExternalLink size={12} /> {t('common.check')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreviewUrl(null);
|
||||
setAnalysisResult(null);
|
||||
setLastSavedId(null);
|
||||
}}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.later')}
|
||||
</button>
|
||||
<button onClick={() => { setPreviewUrl(null); setAnalysisResult(null); setLastSavedId(null); }} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.later')}</button>
|
||||
</div>
|
||||
) : matchingBottle ? (
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
<Link
|
||||
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`}
|
||||
className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40"
|
||||
>
|
||||
<Link href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`} className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40">
|
||||
<ExternalLink size={20} />
|
||||
{t('camera.toVault')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMatchingBottle(null)}
|
||||
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
|
||||
>
|
||||
{t('camera.saveAnyway')}
|
||||
</button>
|
||||
<button onClick={() => setMatchingBottle(null)} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.saveAnyway')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isQueued) {
|
||||
setPreviewUrl(null);
|
||||
} else if (previewUrl && analysisResult) {
|
||||
if (validatedSessionId) {
|
||||
handleQuickSave();
|
||||
} else {
|
||||
handleSave();
|
||||
}
|
||||
} else {
|
||||
triggerUpload();
|
||||
}
|
||||
if (isQueued) setPreviewUrl(null);
|
||||
else if (previewUrl && analysisResult) validatedSessionId ? handleQuickSave() : handleSave();
|
||||
else triggerUpload();
|
||||
}}
|
||||
disabled={isProcessing || isSaving}
|
||||
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-100 text-zinc-900 shadow-black/10' : 'bg-orange-600 hover:bg-orange-700 text-white shadow-orange-950/40'}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
{t('camera.saving')}
|
||||
</>
|
||||
<><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>{t('camera.saving')}</>
|
||||
) : isQueued ? (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.nextBottle')}
|
||||
</>
|
||||
<><CheckCircle2 size={20} />{t('camera.nextBottle')}</>
|
||||
) : previewUrl && analysisResult ? (
|
||||
validatedSessionId ? (
|
||||
<>
|
||||
<Droplets size={20} className="text-orange-500" />
|
||||
{t('camera.quickTasting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.inVault')}
|
||||
</>
|
||||
)
|
||||
validatedSessionId ? <><Droplets size={20} className="text-orange-500" />{t('camera.quickTasting')}</> : <><CheckCircle2 size={20} />{t('camera.inVault')}</>
|
||||
) : previewUrl ? (
|
||||
<>
|
||||
<Upload size={20} />
|
||||
{t('camera.newPhoto')}
|
||||
</>
|
||||
<><Upload size={20} />{t('camera.newPhoto')}</>
|
||||
) : (
|
||||
<>
|
||||
<Camera size={20} />
|
||||
{t('camera.openingCamera')}
|
||||
</>
|
||||
<><Camera size={20} />{t('camera.openingCamera')}</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!previewUrl && !isProcessing && (
|
||||
<button
|
||||
onClick={triggerGallery}
|
||||
className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm"
|
||||
>
|
||||
<Upload size={18} />
|
||||
{t('camera.uploadGallery')}
|
||||
<button onClick={triggerGallery} className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm">
|
||||
<Upload size={18} />{t('camera.uploadGallery')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/10 p-3 rounded-lg w-full">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
<AlertCircle size={16} />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQueued && (
|
||||
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black text-zinc-800 dark:text-zinc-100 italic">Lokal gespeichert!</span>
|
||||
<span className="text-[10px] font-bold text-purple-600 dark:text-purple-400 uppercase tracking-widest">Warteschlange aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchingBottle && !lastSavedId && (
|
||||
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{t('camera.alreadyInVault')}
|
||||
</div>
|
||||
<p className="text-xs text-blue-500/80">
|
||||
{t('camera.alreadyInVaultDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm"><AlertCircle size={16} />{t('camera.alreadyInVault')}</div>
|
||||
<p className="text-xs text-blue-500/80">{t('camera.alreadyInVaultDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Results Display */}
|
||||
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
|
||||
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm bg-green-900/10 p-3 rounded-lg w-full border border-green-900/30">
|
||||
<CheckCircle2 size={16} />
|
||||
{t('camera.analysisSuccess')}
|
||||
<CheckCircle2 size={16} />{t('camera.analysisSuccess')}
|
||||
</div>
|
||||
|
||||
<div className="p-3 md:p-4 bg-zinc-950 rounded-2xl border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600">
|
||||
<Sparkles size={18} />
|
||||
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600"><Sparkles size={18} /><span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span></div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
||||
<span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
|
||||
<span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
|
||||
</div>
|
||||
{analysisResult.age && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.ageLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.distilled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.distilled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.bottled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.bottledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.bottled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.nameLabel')}:</span><span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span></div>
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span><span className="font-semibold text-right">{analysisResult.distillery || '-'}</span></div>
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.categoryLabel')}:</span><span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span></div>
|
||||
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.abvLabel')}:</span><span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span></div>
|
||||
{analysisResult.age && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.ageLabel')}:</span><span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span></div>}
|
||||
{analysisResult.vintage && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">Vintage:</span><span className="font-semibold text-right">{analysisResult.vintage}</span></div>}
|
||||
{analysisResult.bottler && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottler:</span><span className="font-semibold">{analysisResult.bottler}</span></div>}
|
||||
{analysisResult.distilled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.distilledLabel')}:</span><span className="font-semibold">{analysisResult.distilled_at}</span></div>}
|
||||
{analysisResult.bottled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.bottledLabel')}:</span><span className="font-semibold">{analysisResult.bottled_at}</span></div>}
|
||||
{analysisResult.batch_info && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.batchLabel')}:</span><span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span></div>}
|
||||
{analysisResult.bottleCode && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottle Code:</span><span className="font-semibold text-zinc-400 font-mono text-xs">{analysisResult.bottleCode}</span></div>}
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="pt-4 mt-2 border-t border-zinc-900/50 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1">
|
||||
<Clock size={10} /> Performance Data
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1"><Clock size={10} /> Performance Data</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Comp:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span>
|
||||
<div className="flex justify-between"><span className="text-zinc-600">CLIENT:</span><span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span></div>
|
||||
<div className="flex justify-between"><span className="text-zinc-600">({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)</span></div>
|
||||
|
||||
{perfMetrics.cacheHit ? (
|
||||
<div className="col-span-2 text-center py-1">
|
||||
<span className="text-green-500 font-bold text-[11px]">CACHE HIT</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">AI:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.ai.toFixed(0)}ms</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
|
||||
<div className="text-[8px] text-zinc-600 mb-0.5">AI BREAKDOWN:</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Prep:</span>
|
||||
<span className="text-zinc-400 font-mono">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-600">Total:</span>
|
||||
<span className="text-orange-600 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
||||
{perfMetrics.imagePrep !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Prep:</span><span className="text-zinc-400 font-mono">{perfMetrics.imagePrep.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.encoding !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Encode:</span><span className="text-zinc-400 font-mono">{perfMetrics.encoding.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.modelInit !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Init:</span><span className="text-zinc-400 font-mono">{perfMetrics.modelInit.toFixed(0)}ms</span></div>}
|
||||
<div className="flex justify-between"><span className="text-orange-400">API:</span><span className="text-orange-400 font-mono font-bold">{perfMetrics.aiApi.toFixed(0)}ms</span></div>
|
||||
{perfMetrics.validation !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Valid:</span><span className="text-zinc-400 font-mono">{perfMetrics.validation.toFixed(0)}ms</span></div>}
|
||||
{perfMetrics.dbOps !== undefined && <div className="flex justify-between"><span className="text-zinc-600">DB:</span><span className="text-zinc-400 font-mono">{perfMetrics.dbOps.toFixed(0)}ms</span></div>}
|
||||
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
|
||||
<div className="flex justify-between"><span className="text-zinc-500 font-bold">TOTAL:</span><span className="text-orange-500 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai).toFixed(0)}ms</span></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,13 +7,17 @@ import TastingEditor from './TastingEditor';
|
||||
import SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||
|
||||
@@ -21,9 +25,10 @@ interface ScanAndTasteFlowProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageFile: File | null;
|
||||
onBottleSaved?: (bottleId: string) => void;
|
||||
}
|
||||
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAndTasteFlowProps) {
|
||||
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleSaved }: ScanAndTasteFlowProps) {
|
||||
const [state, setState] = useState<FlowState>('IDLE');
|
||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||
const { activeSession } = useSession();
|
||||
@@ -35,13 +40,23 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
const { locale } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
comp: number;
|
||||
aiTotal: number;
|
||||
aiApi: number;
|
||||
aiParse: number;
|
||||
uploadSize: number;
|
||||
prep: number
|
||||
prep: number;
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Admin Check
|
||||
@@ -57,7 +72,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
.maybeSingle();
|
||||
|
||||
if (error) console.error('[ScanFlow] Admin check error:', error);
|
||||
console.log('[ScanFlow] Admin status:', !!data);
|
||||
setIsAdmin(!!data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -67,10 +81,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
checkAdmin();
|
||||
}, [supabase]);
|
||||
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
|
||||
// Trigger scan when open and image provided
|
||||
useEffect(() => {
|
||||
if (isOpen && imageFile) {
|
||||
console.log('[ScanFlow] Starting handleScan...');
|
||||
setAiFallbackActive(false);
|
||||
handleScan(imageFile);
|
||||
} else if (!isOpen) {
|
||||
setState('IDLE');
|
||||
@@ -79,59 +96,143 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
setProcessedImage(null);
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
setAiFallbackActive(false);
|
||||
}
|
||||
}, [isOpen, imageFile]);
|
||||
|
||||
// Online/Offline detection
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
setError(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
console.log('[ScanFlow] Starting image processing...');
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(file);
|
||||
const endComp = performance.now();
|
||||
setProcessedImage(processed);
|
||||
|
||||
console.log('[ScanFlow] Calling magicScan service with FormData (optimized WebP)...');
|
||||
// OFFLINE: Skip AI scan, use dummy metadata
|
||||
if (isOffline) {
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: 0,
|
||||
aiApi: 0,
|
||||
aiParse: 0,
|
||||
uploadSize: processed.file.size,
|
||||
prep: 0,
|
||||
cacheCheck: 0,
|
||||
cacheHit: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ONLINE: Normal AI scan
|
||||
const formData = new FormData();
|
||||
formData.append('file', processed.file);
|
||||
formData.append('provider', 'gemini');
|
||||
formData.append('locale', locale);
|
||||
|
||||
const startAi = performance.now();
|
||||
const result = await magicScan(formData);
|
||||
const result = await scanLabel(formData);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (result.success && result.data) {
|
||||
console.log('[ScanFlow] magicScan success');
|
||||
if (result.raw) {
|
||||
console.log('[ScanFlow] RAW AI RESPONSE:', result.raw);
|
||||
}
|
||||
setBottleMetadata(result.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
if (isAdmin) {
|
||||
if (isAdmin && result.perf) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: endAi - startAi,
|
||||
aiApi: result.perf?.apiDuration || 0,
|
||||
aiParse: result.perf?.parseDuration || 0,
|
||||
uploadSize: result.perf?.uploadSize || 0,
|
||||
prep: endPrep - startPrep
|
||||
aiApi: result.perf.apiCall || result.perf.apiDuration || 0,
|
||||
aiParse: result.perf.parsing || result.perf.parseDuration || 0,
|
||||
uploadSize: result.perf.uploadSize || 0,
|
||||
prep: endPrep - startPrep,
|
||||
imagePrep: result.perf.imagePrep,
|
||||
cacheCheck: result.perf.cacheCheck,
|
||||
encoding: result.perf.encoding,
|
||||
modelInit: result.perf.modelInit,
|
||||
validation: result.perf.validation,
|
||||
dbOps: result.perf.dbOps,
|
||||
total: result.perf.total,
|
||||
cacheHit: result.perf.cacheHit
|
||||
});
|
||||
}
|
||||
|
||||
setState('EDITOR');
|
||||
|
||||
// Step 2: Background Enrichment
|
||||
if (result.data.name && result.data.distillery) {
|
||||
setIsEnriching(true);
|
||||
console.log('[ScanFlow] Starting background enrichment for:', result.data.name);
|
||||
enrichData(result.data.name, result.data.distillery, undefined, locale)
|
||||
.then(enrichResult => {
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
console.log('[ScanFlow] Enrichment data received:', enrichResult.data);
|
||||
setBottleMetadata(prev => {
|
||||
if (!prev) return prev;
|
||||
const updated = {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
search_string: enrichResult.data.search_string
|
||||
};
|
||||
console.log('[ScanFlow] State updated with enriched metadata');
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
console.error('[ScanFlow] magicScan failure:', result.error);
|
||||
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
|
||||
console.warn('[ScanFlow] Enrichment result unsuccessful:', enrichResult.error);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[ScanFlow] Enrichment failed:', err))
|
||||
.finally(() => setIsEnriching(false));
|
||||
}
|
||||
} else if (result.isAiError) {
|
||||
console.warn('[ScanFlow] AI Analysis failed, falling back to offline mode');
|
||||
setIsOffline(true);
|
||||
setAiFallbackActive(true);
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
} else {
|
||||
throw new Error(result.error || 'Fehler bei der Analyse.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[ScanFlow] handleScan error:', err);
|
||||
|
||||
// Check if this is a network error (offline)
|
||||
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError') || err.message?.includes('ERR_INTERNET_DISCONNECTED')) {
|
||||
console.log('[ScanFlow] Network error detected - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
|
||||
// Use dummy metadata for offline scan
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Other errors
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
}
|
||||
@@ -144,11 +245,119 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
// OFFLINE: Save to IndexedDB queue (skip auth check)
|
||||
if (isOffline) {
|
||||
console.log('[ScanFlow] Offline mode - queuing for upload');
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// 1. Save Bottle - Use compressed base64 for storage as well
|
||||
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
// Save pending scan with metadata
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
// Store bottle metadata in a custom field
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
|
||||
// Save pending tasting linked to temp bottle
|
||||
await db.pending_tastings.add({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
session_id: activeSession?.id,
|
||||
rating: formData.rating,
|
||||
nose_notes: formData.nose_notes,
|
||||
palate_notes: formData.palate_notes,
|
||||
finish_notes: formData.finish_notes,
|
||||
is_sample: formData.is_sample,
|
||||
buddy_ids: formData.buddy_ids,
|
||||
tag_ids: formData.tag_ids,
|
||||
},
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setTastingData(formData);
|
||||
setState('RESULT');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ONLINE: Normal save to Supabase
|
||||
let user;
|
||||
try {
|
||||
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
|
||||
if (!authUser) throw new Error('Nicht autorisiert');
|
||||
user = authUser;
|
||||
} catch (authError: any) {
|
||||
// If auth fails due to network, treat as offline
|
||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
|
||||
// Save to queue instead
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
|
||||
await db.pending_tastings.add({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
session_id: activeSession?.id,
|
||||
rating: formData.rating,
|
||||
nose_notes: formData.nose_notes,
|
||||
palate_notes: formData.palate_notes,
|
||||
finish_notes: formData.finish_notes,
|
||||
is_sample: formData.is_sample,
|
||||
buddy_ids: formData.buddy_ids,
|
||||
tag_ids: formData.tag_ids,
|
||||
},
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setTastingData(formData);
|
||||
setState('RESULT');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
// Other auth errors
|
||||
throw authError;
|
||||
}
|
||||
|
||||
// 1. Save Bottle - Use edited metadata if provided
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
@@ -168,6 +377,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
|
||||
setTastingData(tastingNote);
|
||||
setState('RESULT');
|
||||
|
||||
// Trigger bottle list refresh in parent
|
||||
if (onBottleSaved) {
|
||||
onBottleSaved(bottleId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
@@ -250,7 +464,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">AI Engine</p>
|
||||
{perfMetrics.aiApi === 0 ? (
|
||||
{perfMetrics.cacheHit ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
|
||||
<p className="text-[7px] opacity-40 mt-1">DB RESULTS</p>
|
||||
@@ -259,8 +473,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
<>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</p>
|
||||
<div className="flex flex-col gap-0.5 mt-1 text-[7px] opacity-60">
|
||||
<span>API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
||||
<span>Parse: {perfMetrics.aiParse.toFixed(0)}ms</span>
|
||||
{perfMetrics.imagePrep !== undefined && <span>Prep: {perfMetrics.imagePrep.toFixed(0)}ms</span>}
|
||||
{perfMetrics.encoding !== undefined && <span>Encode: {perfMetrics.encoding.toFixed(0)}ms</span>}
|
||||
{perfMetrics.modelInit !== undefined && <span>Init: {perfMetrics.modelInit.toFixed(0)}ms</span>}
|
||||
<span className="text-orange-400">API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
||||
{perfMetrics.validation !== undefined && <span>Valid: {perfMetrics.validation.toFixed(0)}ms</span>}
|
||||
{perfMetrics.dbOps !== undefined && <span>DB: {perfMetrics.dbOps.toFixed(0)}ms</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -307,6 +525,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
exit={{ y: -50, opacity: 0 }}
|
||||
className="flex-1 w-full h-full flex flex-col min-h-0"
|
||||
>
|
||||
{isOffline && (
|
||||
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
|
||||
<p className="text-xs font-bold text-orange-500 uppercase tracking-wider">
|
||||
{aiFallbackActive
|
||||
? 'KI-Dienst nicht erreichbar. Nutze Platzhalter-Daten; Anreicherung erfolgt automatisch im Hintergrund.'
|
||||
: 'Offline Modus - Daten werden hochgeladen wenn du online bist'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TastingEditor
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={processedImage?.base64 || null}
|
||||
@@ -314,6 +544,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||
activeSessionName={activeSession?.name}
|
||||
activeSessionId={activeSession?.id}
|
||||
isEnriching={isEnriching}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||
@@ -326,12 +557,21 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">AI:</span>
|
||||
{perfMetrics.aiApi === 0 ? (
|
||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT ⚡</span>
|
||||
{perfMetrics.cacheHit ? (
|
||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</span>
|
||||
<span className="text-zinc-600 ml-1">(API: {perfMetrics.aiApi.toFixed(0)}ms / Pars: {perfMetrics.aiParse.toFixed(0)}ms)</span>
|
||||
<span className="text-zinc-600 ml-1 text-[10px]">
|
||||
(
|
||||
{perfMetrics.imagePrep !== undefined && `Prep:${perfMetrics.imagePrep.toFixed(0)} `}
|
||||
{perfMetrics.encoding !== undefined && `Enc:${perfMetrics.encoding.toFixed(0)} `}
|
||||
{perfMetrics.modelInit !== undefined && `Init:${perfMetrics.modelInit.toFixed(0)} `}
|
||||
<span className="text-orange-400 font-bold">API:{perfMetrics.aiApi.toFixed(0)}</span>
|
||||
{perfMetrics.validation !== undefined && ` Val:${perfMetrics.validation.toFixed(0)}`}
|
||||
{perfMetrics.dbOps !== undefined && ` DB:${perfMetrics.dbOps.toFixed(0)}`}
|
||||
)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,10 @@ interface TagSelectorProps {
|
||||
label?: string;
|
||||
suggestedTagNames?: string[];
|
||||
suggestedCustomTagNames?: string[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames, isLoading }: TagSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -28,7 +29,6 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
[]
|
||||
);
|
||||
|
||||
const isLoading = tags === undefined;
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
const tagList = tags || [];
|
||||
@@ -57,9 +57,9 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
const selectedTags = (tags || []).filter(t => selectedTagIds.includes(t.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{label && (
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
|
||||
<label className="text-[10px] font-black text-zinc-500 uppercase tracking-[0.15em] block">{label}</label>
|
||||
)}
|
||||
|
||||
{/* Selected Tags */}
|
||||
@@ -70,36 +70,36 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-full text-[10px] font-bold uppercase tracking-tight shadow-sm shadow-orange-600/20 animate-in fade-in zoom-in-95"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-xl text-[10px] font-black uppercase tracking-tight shadow-lg shadow-orange-950/20 animate-in fade-in zoom-in-95 hover:bg-orange-500 transition-colors"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
<X size={12} />
|
||||
<X size={12} strokeWidth={3} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] italic text-zinc-500 font-medium">Noch keine Tags gewählt...</span>
|
||||
<span className="text-[10px] italic text-zinc-600 font-medium">Noch keine Tags gewählt...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Suggest */}
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 text-zinc-500" size={14} />
|
||||
<Search className="absolute left-3.5 text-zinc-500" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Tag suchen oder hinzufügen..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-zinc-900 border border-zinc-800 rounded-xl text-xs focus:ring-1 focus:ring-orange-600 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
{isCreating && (
|
||||
<Loader2 className="absolute right-3 animate-spin text-orange-600" size={14} />
|
||||
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<div className="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-50 w-full mt-2 bg-zinc-950 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredTags.length > 0 ? (
|
||||
filteredTags.map(tag => (
|
||||
<button
|
||||
@@ -109,19 +109,19 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
onToggleTag(tag.id);
|
||||
setSearch('');
|
||||
}}
|
||||
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-300 hover:bg-zinc-800/50 flex items-center justify-between border-b border-zinc-800 last:border-0"
|
||||
className="w-full px-4 py-3 text-left text-[11px] font-bold text-zinc-300 hover:bg-zinc-900 flex items-center justify-between border-b border-zinc-900 last:border-0 transition-colors"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" />}
|
||||
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" strokeWidth={3} />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTag}
|
||||
className="w-full px-4 py-3 text-left text-xs font-bold text-orange-600 hover:bg-orange-950/10 flex items-center gap-2"
|
||||
className="w-full px-4 py-4 text-left text-[11px] font-bold text-orange-500 hover:bg-orange-500/5 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<Plus size={14} strokeWidth={3} />
|
||||
"{search}" als neuen Tag hinzufügen
|
||||
</button>
|
||||
)}
|
||||
@@ -131,36 +131,43 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{!search && suggestedTagNames && suggestedTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-orange-500">
|
||||
<Sparkles size={10} /> {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'}
|
||||
{!search && (isLoading || (suggestedTagNames && suggestedTagNames.length > 0)) && (
|
||||
<div className="space-y-2 py-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-orange-600">
|
||||
{isLoading ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} className="fill-orange-600/20" />}
|
||||
{isLoading ? 'Analysiere Aromen...' : 'KI Vorschläge'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tags || [])
|
||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isLoading ? (
|
||||
[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-7 w-16 bg-zinc-900 animate-pulse rounded-xl" />
|
||||
))
|
||||
) : (
|
||||
(tags || [])
|
||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames?.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
||||
.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-2.5 py-1 rounded-lg bg-orange-950/20 text-orange-500 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-900/30 transition-colors border border-orange-900/50 flex items-center gap-1.5"
|
||||
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Custom Suggestions */}
|
||||
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-zinc-500">
|
||||
<div className="space-y-2 py-1">
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||
Dominante Note anlegen?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedCustomTagNames
|
||||
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
|
||||
.map(name => (
|
||||
@@ -177,7 +184,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
}
|
||||
setCreatingSuggestion(null);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-lg bg-zinc-900/50 text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
|
||||
className="px-3 py-1.5 rounded-xl bg-zinc-950/50 text-zinc-500 text-[10px] font-black uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{creatingSuggestion === name ? <Loader2 size={10} className="animate-spin" /> : <Plus size={10} />}
|
||||
{name}
|
||||
@@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions Chips (limit to 6 random or most common) */}
|
||||
{/* Suggestions Chips */}
|
||||
{!search && (tags || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{(tags || [])
|
||||
@@ -198,7 +205,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
className="px-2.5 py-1 rounded-lg bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-colors border border-zinc-800"
|
||||
className="px-2.5 py-1.5 rounded-xl bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-300 transition-colors border border-zinc-800/50"
|
||||
>
|
||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap } from 'lucide-react';
|
||||
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap, Loader2 } from 'lucide-react';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import TagSelector from './TagSelector';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||
|
||||
interface TastingEditorProps {
|
||||
bottleMetadata: BottleMetadata;
|
||||
@@ -17,9 +18,10 @@ interface TastingEditorProps {
|
||||
onOpenSessions: () => void;
|
||||
activeSessionName?: string;
|
||||
activeSessionId?: string;
|
||||
isEnriching?: boolean;
|
||||
}
|
||||
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId }: TastingEditorProps) {
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching }: TastingEditorProps) {
|
||||
const { t } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [rating, setRating] = useState(85);
|
||||
@@ -38,6 +40,27 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
|
||||
|
||||
// Editable bottle metadata
|
||||
const [bottleName, setBottleName] = useState(bottleMetadata.name || '');
|
||||
const [bottleDistillery, setBottleDistillery] = useState(bottleMetadata.distillery || '');
|
||||
const [bottleAbv, setBottleAbv] = useState(bottleMetadata.abv?.toString() || '');
|
||||
const [bottleAge, setBottleAge] = useState(bottleMetadata.age?.toString() || '');
|
||||
const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
|
||||
|
||||
const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
|
||||
const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
|
||||
const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
|
||||
const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
|
||||
const [bottleDistilledAt, setBottleDistilledAt] = useState(bottleMetadata.distilled_at || '');
|
||||
const [bottleBottledAt, setBottleBottledAt] = useState(bottleMetadata.bottled_at || '');
|
||||
const [showBottleDetails, setShowBottleDetails] = useState(false);
|
||||
|
||||
// Whiskybase discovery
|
||||
const [whiskybaseId, setWhiskybaseId] = useState(bottleMetadata.whiskybaseId || '');
|
||||
const [whiskybaseDiscovery, setWhiskybaseDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
||||
const [isDiscoveringWb, setIsDiscoveringWb] = useState(false);
|
||||
const [whiskybaseError, setWhiskybaseError] = useState<string | null>(null);
|
||||
const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
|
||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||
|
||||
@@ -100,6 +123,42 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
}
|
||||
}, [lastDramInSession]);
|
||||
|
||||
// Automatic Whiskybase discovery when details are expanded
|
||||
useEffect(() => {
|
||||
const searchWhiskybase = async () => {
|
||||
if (showBottleDetails && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
|
||||
setIsDiscoveringWb(true);
|
||||
try {
|
||||
const result = await discoverWhiskybaseId({
|
||||
name: bottleMetadata.name || '',
|
||||
distillery: bottleMetadata.distillery ?? undefined,
|
||||
abv: bottleMetadata.abv ?? undefined,
|
||||
age: bottleMetadata.age ?? undefined,
|
||||
batch_info: bottleMetadata.batch_info ?? undefined,
|
||||
distilled_at: bottleMetadata.distilled_at ?? undefined,
|
||||
bottled_at: bottleMetadata.bottled_at ?? undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.id) {
|
||||
setWhiskybaseDiscovery({ id: result.id, url: result.url, title: result.title });
|
||||
setWhiskybaseId(result.id);
|
||||
setWhiskybaseError(null);
|
||||
} else {
|
||||
// No results found
|
||||
setWhiskybaseError('Keine Ergebnisse gefunden');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[TastingEditor] Whiskybase discovery failed:', err);
|
||||
setWhiskybaseError(err.message || 'Suche fehlgeschlagen');
|
||||
} finally {
|
||||
setIsDiscoveringWb(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
searchWhiskybase();
|
||||
}, [showBottleDetails]); // Only trigger when details are expanded
|
||||
|
||||
const toggleBuddy = (id: string) => {
|
||||
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
|
||||
};
|
||||
@@ -118,27 +177,29 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
taste: tasteScore,
|
||||
finish: finishScore,
|
||||
complexity: complexityScore,
|
||||
balance: balanceScore
|
||||
balance: balanceScore,
|
||||
// Edited bottle metadata
|
||||
bottleMetadata: {
|
||||
...bottleMetadata,
|
||||
name: bottleName || bottleMetadata.name,
|
||||
distillery: bottleDistillery || bottleMetadata.distillery,
|
||||
abv: bottleAbv ? parseFloat(bottleAbv) : bottleMetadata.abv,
|
||||
age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
|
||||
category: bottleCategory || bottleMetadata.category,
|
||||
vintage: bottleVintage || null,
|
||||
bottler: bottleBottler || null,
|
||||
batch_info: bottleBatchInfo || null,
|
||||
bottleCode: bottleCode || null,
|
||||
distilled_at: bottleDistilledAt || null,
|
||||
bottled_at: bottleBottledAt || null,
|
||||
whiskybaseId: whiskybaseId || null,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col w-full bg-zinc-950 h-full overflow-hidden">
|
||||
{/* Top Context Bar - Flex Child 1 */}
|
||||
<div className="w-full bg-zinc-900 border-b border-zinc-800 shrink-0">
|
||||
<button
|
||||
onClick={onOpenSessions}
|
||||
className="max-w-2xl mx-auto w-full p-6 flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500">Kontext</p>
|
||||
<p className="font-bold text-zinc-50 leading-none mt-1">{activeSessionName || 'Trinkst du in Gesellschaft?'}</p>
|
||||
</div>
|
||||
<ChevronDown size={20} className="text-orange-500 group-hover:translate-y-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Scrollable Content - Flex Child 2 */}
|
||||
{/* Main Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
|
||||
{/* Palette Warning */}
|
||||
@@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-3xl font-bold text-orange-600 mb-1 truncate leading-none uppercase tracking-tight">
|
||||
{bottleMetadata.distillery || 'Destillerie'}
|
||||
{bottleDistillery || 'Destillerie'}
|
||||
</h1>
|
||||
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleMetadata.name || 'Unbekannter Malt'}</p>
|
||||
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleName || 'Unbekannter Malt'}</p>
|
||||
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest leading-none">
|
||||
{bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `• ${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `• ${bottleMetadata.age}y` : ''}
|
||||
{bottleCategory || 'Whisky'} {bottleAbv ? `• ${bottleAbv}%` : ''} {bottleAge ? `• ${bottleAge}y` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Bottle Details */}
|
||||
<div className="bg-zinc-900/50 rounded-2xl border border-zinc-800 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowBottleDetails(!showBottleDetails)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-zinc-900/70 transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||
Bottle Details
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-zinc-500 transition-transform ${showBottleDetails ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* Vintage */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Vintage
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleVintage}
|
||||
onChange={(e) => setBottleVintage(e.target.value)}
|
||||
placeholder="e.g. 2007"
|
||||
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>
|
||||
|
||||
{/* Bottler */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottler
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBottler}
|
||||
onChange={(e) => setBottleBottler(e.target.value)}
|
||||
placeholder="e.g. Independent Bottler"
|
||||
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>
|
||||
|
||||
{/* Distilled At */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Distilled At
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleDistilledAt}
|
||||
onChange={(e) => setBottleDistilledAt(e.target.value)}
|
||||
placeholder="e.g. 2007"
|
||||
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>
|
||||
|
||||
{/* Bottled At */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottled At
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBottledAt}
|
||||
onChange={(e) => setBottleBottledAt(e.target.value)}
|
||||
placeholder="e.g. 2024"
|
||||
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>
|
||||
|
||||
{/* Batch Info */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Batch Info
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleBatchInfo}
|
||||
onChange={(e) => setBottleBatchInfo(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>
|
||||
|
||||
{/* Bottle Code */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Bottle Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bottleCode}
|
||||
onChange={(e) => setBottleCode(e.target.value)}
|
||||
placeholder="e.g. WB271235"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Whiskybase Discovery */}
|
||||
{isDiscoveringWb && (
|
||||
<div className="flex items-center gap-2 p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<Loader2 size={16} className="animate-spin text-orange-500" />
|
||||
<span className="text-xs text-zinc-400">Searching Whiskybase...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{whiskybaseDiscovery && (
|
||||
<div className="p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[9px] font-bold uppercase tracking-wider text-orange-500 mb-1">
|
||||
Whiskybase Found
|
||||
</p>
|
||||
<p className="text-xs text-zinc-200 truncate">
|
||||
{whiskybaseDiscovery.title}
|
||||
</p>
|
||||
<a
|
||||
href={whiskybaseDiscovery.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-orange-500 hover:underline mt-1 inline-block"
|
||||
>
|
||||
View on Whiskybase →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 font-mono">
|
||||
ID: {whiskybaseDiscovery.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whiskybase Error */}
|
||||
{whiskybaseError && !whiskybaseDiscovery && !isDiscoveringWb && (
|
||||
<div className="p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
|
||||
<p className="text-xs text-zinc-500">
|
||||
{whiskybaseError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Selector */}
|
||||
<button
|
||||
onClick={onOpenSessions}
|
||||
className="w-full p-6 bg-zinc-900/50 rounded-2xl border border-zinc-800 flex items-center justify-between group hover:bg-zinc-900/70 hover:border-orange-500/30 transition-all"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">
|
||||
Session
|
||||
</p>
|
||||
<p className="font-bold text-zinc-50 leading-none">
|
||||
{activeSessionName || 'Trinkst du in Gesellschaft?'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeSessionName && (
|
||||
<Users size={18} className="text-orange-500" />
|
||||
)}
|
||||
<ChevronDown size={18} className="text-zinc-500 group-hover:text-orange-500 group-hover:translate-y-0.5 transition-all" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Rating Slider */}
|
||||
<div className="space-y-6 bg-zinc-900 p-8 rounded-3xl border border-zinc-800 shadow-inner relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||
@@ -252,6 +552,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -289,6 +590,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -383,13 +685,14 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Footer - Flex Child 3 */}
|
||||
<div className="w-full p-8 bg-zinc-950 border-t border-zinc-800 shrink-0">
|
||||
{/* Fixed/Sticky Footer for Save Action */}
|
||||
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={handleInternalSave}
|
||||
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-xl active:scale-[0.98] transition-all"
|
||||
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-2xl shadow-orange-950/40 active:scale-[0.98] transition-all hover:bg-orange-500"
|
||||
>
|
||||
<Send size={20} />
|
||||
{t('tasting.saveTasting')}
|
||||
@@ -398,7 +701,6 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -439,10 +439,12 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky Save Button Container */}
|
||||
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10"
|
||||
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-2xl shadow-black/50 border border-white/5"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : (
|
||||
<>
|
||||
@@ -451,6 +453,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,31 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
||||
import { magicScan } from '@/services/magic-scan';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
|
||||
import TastingNoteForm from './TastingNoteForm';
|
||||
|
||||
// Helper to convert base64 to FormData
|
||||
function base64ToFormData(base64: string, filename: string = 'image.webp'): FormData {
|
||||
const arr = base64.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/webp';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
const file = new File([u8arr], filename, { type: mime });
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export default function UploadQueue() {
|
||||
const supabase = createClient();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
@@ -23,9 +41,12 @@ export default function UploadQueue() {
|
||||
|
||||
const totalInQueue = pendingScans.length + pendingTastings.length;
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
|
||||
const syncInProgress = React.useRef(false);
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (syncInProgress.current || !navigator.onLine) return;
|
||||
|
||||
syncInProgress.current = true;
|
||||
setIsSyncing(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
@@ -36,16 +57,78 @@ export default function UploadQueue() {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Sync Scans (Magic Shots)
|
||||
for (const item of pendingScans) {
|
||||
// 1. Sync Scans (Magic Shots) - Two-Step Flow
|
||||
// We use a transaction to "claim" items currently not being synced by another tab/instance
|
||||
const scansToSync = await db.transaction('rw', db.pending_scans, async () => {
|
||||
const all = await db.pending_scans.toArray();
|
||||
const now = Date.now();
|
||||
const available = all.filter(i => {
|
||||
if (i.syncing) return false;
|
||||
// Exponential backoff: don't retry immediately if it failed before
|
||||
if (i.attempts && i.attempts > 0) {
|
||||
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000); // Max 30s
|
||||
const lastAttempt = i.timestamp; // We use timestamp for simplicity or add last_attempt
|
||||
// For now we trust timestamp + backoff if timestamp is updated on fail
|
||||
return (now - i.timestamp) > backoff;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const item of available) {
|
||||
await db.pending_scans.update(item.id!, { syncing: 1 });
|
||||
}
|
||||
return available;
|
||||
});
|
||||
|
||||
for (const item of scansToSync) {
|
||||
const itemId = `scan-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
|
||||
setCurrentProgress({ id: itemId, status: 'OCR Analyse...' });
|
||||
try {
|
||||
const analysis = await magicScan(item.imageBase64, item.provider, item.locale);
|
||||
if (analysis.success && analysis.data) {
|
||||
const bottleData = analysis.data;
|
||||
let bottleData;
|
||||
|
||||
// Check if this is an offline scan with pre-filled metadata
|
||||
// CRITICAL: If name is empty, it's placeholder metadata and needs OCR enrichment
|
||||
if (item.metadata && item.metadata.name && item.metadata.name.trim().length > 0) {
|
||||
console.log('[UploadQueue] Valid offline metadata found - skipping OCR');
|
||||
bottleData = item.metadata;
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Offline-Scan...' });
|
||||
} else {
|
||||
console.log('[UploadQueue] No valid metadata - running OCR analysis');
|
||||
// Normal online scan - perform AI analysis
|
||||
// Step 1: Fast OCR
|
||||
const formData = base64ToFormData(item.imageBase64);
|
||||
const ocrResult = await scanLabel(formData);
|
||||
|
||||
if (ocrResult.success && ocrResult.data) {
|
||||
bottleData = ocrResult.data;
|
||||
|
||||
// Step 2: Background enrichment (before saving)
|
||||
if (bottleData.is_whisky && bottleData.name && bottleData.distillery) {
|
||||
setCurrentProgress({ id: itemId, status: 'Enrichment...' });
|
||||
const enrichResult = await enrichData(
|
||||
bottleData.name,
|
||||
bottleData.distillery,
|
||||
undefined,
|
||||
item.locale
|
||||
);
|
||||
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
// Merge enrichment data into bottle data
|
||||
bottleData = {
|
||||
...bottleData,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(ocrResult.error || 'Analyse fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Save bottle with all data
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
||||
|
||||
if (save.success && save.data) {
|
||||
const newBottleId = save.data.id;
|
||||
|
||||
@@ -72,25 +155,41 @@ export default function UploadQueue() {
|
||||
}]);
|
||||
await db.pending_scans.delete(item.id!);
|
||||
}
|
||||
} else {
|
||||
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
console.error('Scan sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
|
||||
// Wait a bit before next
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||
// Unmark as syncing on failure, update attempts and timestamp for backoff
|
||||
await db.pending_scans.update(item.id!, {
|
||||
syncing: 0,
|
||||
attempts: (item.attempts || 0) + 1,
|
||||
last_error: errorMessage,
|
||||
timestamp: Date.now() // Update timestamp to use for backoff
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync Tastings
|
||||
for (const item of pendingTastings) {
|
||||
// If it still has a pending_bottle_id, it means the scan hasn't synced yet.
|
||||
// We SKIP this tasting and wait for the scan to finish in a future loop.
|
||||
if (item.pending_bottle_id) {
|
||||
continue;
|
||||
const tastingsToSync = await db.transaction('rw', db.pending_tastings, async () => {
|
||||
const all = await db.pending_tastings.toArray();
|
||||
const now = Date.now();
|
||||
const available = all.filter(i => {
|
||||
if (i.syncing || i.pending_bottle_id) return false;
|
||||
// Exponential backoff
|
||||
if (i.attempts && i.attempts > 0) {
|
||||
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000);
|
||||
return (now - new Date(i.tasted_at).getTime()) > backoff;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const item of available) {
|
||||
await db.pending_tastings.update(item.id!, { syncing: 1 });
|
||||
}
|
||||
return available;
|
||||
});
|
||||
|
||||
for (const item of tastingsToSync) {
|
||||
const itemId = `tasting-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
||||
try {
|
||||
@@ -112,36 +211,61 @@ export default function UploadQueue() {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
console.error('Tasting sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||
await db.pending_tastings.update(item.id!, {
|
||||
syncing: 0,
|
||||
attempts: (item.attempts || 0) + 1,
|
||||
last_error: errorMessage
|
||||
// Note: we use tasted_at or add a last_attempt for backoff.
|
||||
// For now let's just use the tried attempts as a counter and a fixed wait.
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Global Sync Error:', err);
|
||||
} finally {
|
||||
syncInProgress.current = false;
|
||||
setIsSyncing(false);
|
||||
setCurrentProgress(null);
|
||||
}
|
||||
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
|
||||
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('Online! Waiting 2s for network stability...');
|
||||
setTimeout(() => {
|
||||
syncQueue();
|
||||
}, 2000);
|
||||
console.log('Online! Syncing in 2s...');
|
||||
setTimeout(syncQueue, 2000);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
|
||||
// Initial check if we are online and have items
|
||||
// Initial check: only trigger if online and items exist,
|
||||
// and we aren't already syncing.
|
||||
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
|
||||
syncQueue();
|
||||
// we use a small timeout to debounce background sync
|
||||
const timer = setTimeout(syncQueue, 3000);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, [totalInQueue, syncQueue, isSyncing]);
|
||||
// Trigger when the presence of items changes or online status
|
||||
}, [syncQueue, totalInQueue > 0]); // Removed isSyncing to break the loop
|
||||
|
||||
// Clear stale syncing flags on mount
|
||||
useEffect(() => {
|
||||
const clearStaleFlags = async () => {
|
||||
await db.transaction('rw', [db.pending_scans, db.pending_tastings], async () => {
|
||||
await db.pending_scans.where('syncing').equals(1).modify({ syncing: 0 });
|
||||
await db.pending_tastings.where('syncing').equals(1).modify({ syncing: 0 });
|
||||
});
|
||||
};
|
||||
clearStaleFlags();
|
||||
}, []);
|
||||
|
||||
if (totalInQueue === 0) return null;
|
||||
|
||||
|
||||
@@ -1,46 +1,68 @@
|
||||
export const getSystemPrompt = (availableTags: string, language: string) => `
|
||||
TASK: Analyze this whisky bottle image. Return raw JSON.
|
||||
export const getOcrPrompt = () => `
|
||||
ROLE: High-Precision OCR Engine for Whisky Labels.
|
||||
OBJECTIVE: Extract visible metadata strictly from the image.
|
||||
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
|
||||
|
||||
STEP 1: IDENTIFICATION (OCR & EXTRACTION)
|
||||
Extract exact text and details from the label. Look closely for specific dates and codes.
|
||||
- name: Full whisky name (e.g. "Lagavulin 16 Year Old")
|
||||
- distillery: Distillery name
|
||||
- bottler: Independent bottler if applicable
|
||||
- category: Type (e.g. "Islay Single Malt", "Bourbon")
|
||||
- abv: Alcohol percentage (number only)
|
||||
- age: Age statement in years (number only)
|
||||
- vintage: Vintage year (e.g. "1995")
|
||||
- distilled_at: Distillation date/year if specified
|
||||
- bottled_at: Bottling date/year if specified
|
||||
- batch_info: Cask number, Batch ID, or Bottle number (e.g. "Batch 001", "Cask #402")
|
||||
- bottleCode: Laser codes etched on glass/label (e.g. "L1234...")
|
||||
- whiskybaseId: Whiskybase ID if clearly printed (rare, but check)
|
||||
TASK:
|
||||
1. Identify if the image contains a whisky/spirit bottle.
|
||||
2. Extract the following technical details into the JSON schema below.
|
||||
3. If a value is not visible or cannot be inferred with high certainty, use null.
|
||||
|
||||
STEP 2: SENSORY "MAGIC" (KNOWLEDGE RETRIEVAL)
|
||||
Use the IDENTIFIED NAME from Step 1 to query your internal knowledge base for the flavor profile.
|
||||
DO NOT try to "see" the flavor in the pixels. Use your expert knowledge about this specific whisky edition.
|
||||
- Match flavors strictly against this list: ${availableTags}
|
||||
- Select top 5-8 matching tags.
|
||||
- If distinct notes are missing from the list, add 1-2 unique ones to "suggested_custom_tags" (localized in ${language === 'de' ? 'German' : 'English'}).
|
||||
EXTRACTION RULES:
|
||||
- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
|
||||
- Distillery: The producer of the spirit.
|
||||
- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
|
||||
- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
|
||||
- Codes: Look for laser codes etched on glass/label (e.g., "L20394...").
|
||||
- Dates: Distinguish clearly between Vintage (distilled year), Bottled year, and Age.
|
||||
|
||||
OUTPUT SCHEMA (Strict JSON):
|
||||
{
|
||||
"name": "string",
|
||||
"distillery": "string",
|
||||
"category": "string",
|
||||
"abv": number or null,
|
||||
"age": number or null,
|
||||
"vintage": "string or null",
|
||||
"distilled_at": "string or null",
|
||||
"bottled_at": "string or null",
|
||||
"batch_info": "string or null",
|
||||
"bottleCode": "string or null",
|
||||
"whiskybaseId": "string or null",
|
||||
"bottler": "stringOrNull",
|
||||
"category": "string (e.g. Single Malt Scotch Whisky)",
|
||||
"abv": numberOrNull,
|
||||
"age": numberOrNull,
|
||||
"vintage": "stringOrNull",
|
||||
"distilled_at": "stringOrNull (Year/Date)",
|
||||
"bottled_at": "stringOrNull (Year/Date)",
|
||||
"batch_info": "stringOrNull",
|
||||
"bottleCode": "stringOrNull",
|
||||
"whiskybaseId": "stringOrNull",
|
||||
"is_whisky": boolean,
|
||||
"confidence": number,
|
||||
"suggested_tags": ["tag1", "tag2"],
|
||||
"suggested_custom_tags": ["custom1"],
|
||||
"search_string": "site:whiskybase.com [Distillery] [Name] [Vintage/Age]"
|
||||
"confidence": number
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export const getEnrichmentPrompt = (name: string, distillery: string, availableTags: string, language: string) => `
|
||||
TASK: You are a Whisky Sommelier.
|
||||
INPUT: A whisky named "${name}" from distillery "${distillery}".
|
||||
|
||||
1. DATABASE LOOKUP:
|
||||
Retrieve the sensory profile and specific Whiskybase search string for this bottling.
|
||||
Use your expert knowledge.
|
||||
|
||||
2. TAGGING:
|
||||
Select the top 5-8 flavor tags strictly from this list:
|
||||
[${availableTags}]
|
||||
|
||||
3. SEARCH STRING:
|
||||
Create a precise search string for Whiskybase using: "site:whiskybase.com [Distillery] [Vintage/Age] [Bottler/Edition]"
|
||||
|
||||
OUTPUT JSON:
|
||||
{
|
||||
"suggested_tags": ["tag1", "tag2", "tag3"],
|
||||
"suggested_custom_tags": ["uniquer_note_if_missing_in_list"],
|
||||
"search_string": "string"
|
||||
}
|
||||
`;
|
||||
|
||||
// Legacy support (to avoid immediate breaking changes while refactoring)
|
||||
export const getSystemPrompt = (availableTags: string, language: string) => `
|
||||
${getOcrPrompt()}
|
||||
Additionally, provide:
|
||||
- suggested_tags: string[] (matched against [${availableTags}])
|
||||
- suggested_custom_tags: string[]
|
||||
- search_string: string
|
||||
`;
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface PendingScan {
|
||||
timestamp: number;
|
||||
provider?: 'gemini' | 'mistral';
|
||||
locale?: string;
|
||||
metadata?: any; // Bottle metadata for offline scans
|
||||
syncing?: number; // 0 or 1 for indexing
|
||||
attempts?: number;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface PendingTasting {
|
||||
@@ -25,6 +29,9 @@ export interface PendingTasting {
|
||||
};
|
||||
photo?: string;
|
||||
tasted_at: string;
|
||||
syncing?: number; // 0 or 1 for indexing
|
||||
attempts?: number;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface CachedTag {
|
||||
@@ -80,9 +87,9 @@ export class WhiskyDexie extends Dexie {
|
||||
|
||||
constructor() {
|
||||
super('WhiskyVault');
|
||||
this.version(4).stores({
|
||||
pending_scans: '++id, temp_id, timestamp, locale',
|
||||
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at',
|
||||
this.version(6).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',
|
||||
cache_buddies: 'id, name',
|
||||
cache_bottles: 'id, name, distillery',
|
||||
|
||||
@@ -5,7 +5,6 @@ const apiKey = process.env.GEMINI_API_KEY!;
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
|
||||
export const geminiModel = genAI.getGenerativeModel({
|
||||
//model: 'gemini-3-flash-preview',
|
||||
model: 'gemini-2.5-flash',
|
||||
generationConfig: {
|
||||
responseMimeType: 'application/json',
|
||||
|
||||
@@ -33,13 +33,13 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
||||
|
||||
if (!isStatic) {
|
||||
const status = session ? `User:${session.user.id.slice(0, 8)}` : 'No Session';
|
||||
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
|
||||
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
||||
|
||||
// 2. Auth & Credits
|
||||
supabase = await createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session || !session.user) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const userId = user.id;
|
||||
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||
if (!creditCheck.allowed) {
|
||||
return {
|
||||
@@ -85,6 +85,7 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
||||
|
||||
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
||||
|
||||
try {
|
||||
const startApi = performance.now();
|
||||
const chatResponse = await client.chat.complete({
|
||||
model: 'mistral-large-latest',
|
||||
@@ -117,23 +118,19 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
||||
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||
console.log('[Mistral AI] JSON Response:', jsonData);
|
||||
|
||||
// Extract search_string before validation
|
||||
const searchString = jsonData.search_string;
|
||||
delete jsonData.search_string;
|
||||
|
||||
// Ensure abv is a number if it came as a string
|
||||
if (typeof jsonData.abv === 'string') {
|
||||
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
|
||||
}
|
||||
|
||||
// Ensure age/vintage are numbers
|
||||
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
|
||||
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
|
||||
|
||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endParse = performance.now();
|
||||
|
||||
// Track usage
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
@@ -141,10 +138,8 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
||||
success: true
|
||||
});
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
|
||||
|
||||
// Store in Cache
|
||||
await supabase
|
||||
.from('vision_cache')
|
||||
.insert({ hash: imageHash, result: validatedData });
|
||||
@@ -161,22 +156,27 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
||||
raw: jsonData
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mistral Analysis Error:', error);
|
||||
} catch (aiError: any) {
|
||||
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
|
||||
|
||||
if (supabase) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
await trackApiUsage({
|
||||
userId: session.user.id,
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'mistral/mistral-large',
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
||||
errorMessage: aiError.message
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
isAiError: true,
|
||||
error: aiError.message,
|
||||
imageHash: imageHash
|
||||
} as any;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mistral Analysis Global Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
|
||||
|
||||
@@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
|
||||
// 2. Auth & Credits (bleibt gleich)
|
||||
supabase = await createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!session || !session.user) {
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const userId = user.id;
|
||||
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||
|
||||
if (!creditCheck.allowed) {
|
||||
@@ -80,14 +80,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
}
|
||||
|
||||
// 5. Für Gemini vorbereiten
|
||||
// Wir müssen es hier zwar zu Base64 machen, aber Node.js (C++) macht das
|
||||
// extrem effizient. Das Problem vorher war der JSON Parser von Next.js.
|
||||
const base64Data = buffer.toString('base64');
|
||||
const mimeType = file.type || 'image/webp'; // Fallback
|
||||
const mimeType = file.type || 'image/webp';
|
||||
const uploadSize = buffer.length;
|
||||
|
||||
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
||||
|
||||
try {
|
||||
// API Call
|
||||
const startApi = performance.now();
|
||||
const result = await geminiModel.generateContent([
|
||||
@@ -104,12 +103,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
const startParse = performance.now();
|
||||
const responseText = result.response.text();
|
||||
|
||||
// JSON Parsing der ANTWORT (das ist klein, das schafft der N100 locker)
|
||||
let jsonData;
|
||||
try {
|
||||
jsonData = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
// Fallback falls Gemini Markdown ```json Blöcke schickt
|
||||
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
|
||||
jsonData = JSON.parse(cleanedText);
|
||||
}
|
||||
@@ -125,7 +122,7 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endParse = performance.now();
|
||||
|
||||
// 6. Tracking & Credits (bleibt gleich)
|
||||
// 6. Tracking & Credits
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
@@ -136,12 +133,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
|
||||
|
||||
// Cache speichern
|
||||
const { error: storeError } = await supabase
|
||||
await supabase
|
||||
.from('vision_cache')
|
||||
.insert({ hash: imageHash, result: validatedData });
|
||||
|
||||
if (storeError) console.warn(`[AI Cache] Storage failed: ${storeError.message}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
@@ -154,22 +149,27 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||
raw: jsonData
|
||||
} as any;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Gemini Analysis Error:', error);
|
||||
// Error Tracking Logic (bleibt gleich)
|
||||
if (supabase) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
} catch (aiError: any) {
|
||||
console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message);
|
||||
|
||||
await trackApiUsage({
|
||||
userId: session.user.id,
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'generateContent',
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
||||
errorMessage: aiError.message
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
isAiError: true,
|
||||
error: aiError.message,
|
||||
imageHash: imageHash
|
||||
} as any;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Gemini Analysis Global Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
||||
|
||||
@@ -8,12 +8,12 @@ export async function addBuddy(rawData: BuddyData) {
|
||||
|
||||
try {
|
||||
const { name } = BuddySchema.parse(rawData);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('buddies')
|
||||
.insert([{ name, user_id: session.user.id }])
|
||||
.insert([{ name, user_id: user.id }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -32,14 +32,14 @@ export async function deleteBuddy(id: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('buddies')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', session.user.id);
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function deleteBottle(bottleId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Nicht autorisiert.');
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function deleteBottle(bottleId: string) {
|
||||
throw new Error('Flasche nicht gefunden.');
|
||||
}
|
||||
|
||||
if (bottle.user_id !== session.user.id) {
|
||||
if (bottle.user_id !== user.id) {
|
||||
throw new Error('Keine Berechtigung.');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function deleteSession(sessionId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Nicht autorisiert.');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function deleteSession(sessionId: string) {
|
||||
.from('tasting_sessions')
|
||||
.delete()
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', session.user.id);
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Nicht autorisiert.');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
|
||||
.from('tastings')
|
||||
.delete()
|
||||
.eq('id', tastingId)
|
||||
.eq('user_id', session.user.id);
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ export async function findMatchingBottle(metadata: BottleMetadata) {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return null;
|
||||
|
||||
const userId = session.user.id;
|
||||
const userId = user.id;
|
||||
|
||||
// 1. Try matching by Whiskybase ID (most reliable)
|
||||
if (metadata.whiskybaseId) {
|
||||
|
||||
@@ -14,12 +14,12 @@ export async function saveBottle(
|
||||
|
||||
try {
|
||||
const metadata = BottleMetadataSchema.parse(rawMetadata);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Nicht autorisiert oder Session abgelaufen.');
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const userId = user.id;
|
||||
let finalImageUrl = preUploadedUrl;
|
||||
|
||||
// 1. Upload Image to Storage if not already uploaded
|
||||
@@ -50,6 +50,26 @@ export async function saveBottle(
|
||||
throw new Error('Kein Bild zum Speichern vorhanden.');
|
||||
}
|
||||
|
||||
// 1.5 Deduplication Check
|
||||
// If a bottle with the same name/distillery was created by the same user in the last 5 minutes,
|
||||
// we treat it as a duplicate (likely from a race condition or double sync).
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const { data: existingBottle } = await supabase
|
||||
.from('bottles')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('name', metadata.name)
|
||||
.eq('distillery', metadata.distillery)
|
||||
.gte('created_at', fiveMinutesAgo)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingBottle) {
|
||||
console.log('[saveBottle] Potential duplicate detected, returning existing bottle:', existingBottle.id);
|
||||
return { success: true, data: existingBottle };
|
||||
}
|
||||
|
||||
// 2. Save Metadata to Database
|
||||
const { data: bottleData, error: dbError } = await supabase
|
||||
.from('bottles')
|
||||
@@ -64,7 +84,7 @@ export async function saveBottle(
|
||||
image_url: finalImageUrl,
|
||||
status: 'sealed',
|
||||
is_whisky: metadata.is_whisky ?? true,
|
||||
confidence: metadata.confidence ?? 100,
|
||||
confidence: metadata.confidence ? Math.round(metadata.confidence * 100) : 100,
|
||||
distilled_at: metadata.distilled_at,
|
||||
bottled_at: metadata.bottled_at,
|
||||
batch_info: metadata.batch_info,
|
||||
|
||||
@@ -11,8 +11,8 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
|
||||
try {
|
||||
const data = TastingNoteSchema.parse(rawData);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
// Validate Session Age (12 hour limit)
|
||||
if (data.session_id) {
|
||||
@@ -26,7 +26,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
.from('tastings')
|
||||
.insert({
|
||||
bottle_id: data.bottle_id,
|
||||
user_id: session.user.id,
|
||||
user_id: user.id,
|
||||
session_id: data.session_id,
|
||||
rating: data.rating,
|
||||
nose_notes: data.nose_notes,
|
||||
@@ -46,7 +46,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
const buddies = data.buddy_ids.map(buddyId => ({
|
||||
tasting_id: tasting.id,
|
||||
buddy_id: buddyId,
|
||||
user_id: session.user.id
|
||||
user_id: user.id
|
||||
}));
|
||||
const { error: tagError } = await supabase
|
||||
.from('tasting_buddies')
|
||||
@@ -64,7 +64,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
const aromaTags = data.tag_ids.map(tagId => ({
|
||||
tasting_id: tasting.id,
|
||||
tag_id: tagId,
|
||||
user_id: session.user.id
|
||||
user_id: user.id
|
||||
}));
|
||||
const { error: aromaTagError } = await supabase
|
||||
.from('tasting_tags')
|
||||
|
||||
@@ -74,8 +74,8 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
|
||||
|
||||
try {
|
||||
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
@@ -83,7 +83,7 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
|
||||
name,
|
||||
category,
|
||||
is_system_default: false,
|
||||
created_by: session.user.id
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Nicht autorisiert');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
|
||||
finished_at: status === 'empty' ? new Date().toISOString() : null
|
||||
})
|
||||
.eq('id', bottleId)
|
||||
.eq('user_id', session.user.id);
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
|
||||
@@ -10,8 +10,8 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
||||
|
||||
try {
|
||||
const data = UpdateBottleSchema.parse(rawData);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Nicht autorisiert');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('bottles')
|
||||
@@ -29,7 +29,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', bottleId)
|
||||
.eq('user_id', session.user.id);
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
export const BottleMetadataSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255).nullish(),
|
||||
distillery: z.string().trim().max(255).nullish(),
|
||||
bottler: z.string().trim().max(255).nullish(),
|
||||
category: z.string().trim().max(100).nullish(),
|
||||
abv: z.number().min(0).max(100).nullish(),
|
||||
age: z.number().min(0).max(100).nullish(),
|
||||
@@ -76,12 +77,12 @@ 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).optional(),
|
||||
abv: z.number().min(0).max(100).optional(),
|
||||
age: z.number().min(0).max(100).optional(),
|
||||
distilled_at: z.string().trim().max(50).optional(),
|
||||
bottled_at: z.string().trim().max(50).optional(),
|
||||
batch_info: z.string().trim().max(255).optional(),
|
||||
distillery: z.string().trim().max(255).nullish(),
|
||||
abv: z.number().min(0).max(100).nullish(),
|
||||
age: z.number().min(0).max(100).nullish(),
|
||||
distilled_at: z.string().trim().max(50).nullish(),
|
||||
bottled_at: z.string().trim().max(50).nullish(),
|
||||
batch_info: z.string().trim().max(255).nullish(),
|
||||
});
|
||||
|
||||
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
||||
@@ -96,10 +97,25 @@ export interface AnalysisResponse {
|
||||
success: boolean;
|
||||
data?: BottleMetadata;
|
||||
error?: string;
|
||||
isAiError?: boolean;
|
||||
imageHash?: string;
|
||||
perf?: {
|
||||
apiDuration: number;
|
||||
parseDuration: number;
|
||||
// Legacy fields (kept for backward compatibility)
|
||||
apiDuration?: number;
|
||||
parseDuration?: number;
|
||||
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
apiCall?: number;
|
||||
parsing?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
uploadSize: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
};
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
24
src/utils/generate-dummy-metadata.ts
Normal file
24
src/utils/generate-dummy-metadata.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
|
||||
/**
|
||||
* Generate placeholder metadata for offline scans.
|
||||
* Returns editable dummy data that user can fill in manually.
|
||||
*/
|
||||
export function generateDummyMetadata(imageFile: File): BottleMetadata {
|
||||
return {
|
||||
name: '', // Empty - user must fill in
|
||||
distillery: '', // Empty - user must fill in
|
||||
category: 'Whisky',
|
||||
is_whisky: true,
|
||||
confidence: 0,
|
||||
abv: null,
|
||||
age: null,
|
||||
vintage: null,
|
||||
bottler: null,
|
||||
batch_info: null,
|
||||
bottleCode: null,
|
||||
distilled_at: null,
|
||||
bottled_at: null,
|
||||
whiskybaseId: null,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export async function processImageForAI(file: File): Promise<ProcessedImage> {
|
||||
maxSizeMB: 0.4,
|
||||
maxWidthOrHeight: 1024,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/webp'
|
||||
fileType: 'image/webp',
|
||||
libURL: '/lib/browser-image-compression.js'
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user