diff --git a/.aiideas b/.aiideas index ccb5919..a1bd0d5 100644 --- a/.aiideas +++ b/.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 - } - ``` -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. +### 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. -**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. \ No newline at end of file +**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 +} + +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. \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/package.json b/package.json index 3c65212..1ca3d1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9745f10..a969589 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/public/lib/browser-image-compression.js b/public/lib/browser-image-compression.js new file mode 100644 index 0000000..c7fe920 --- /dev/null +++ b/public/lib/browser-image-compression.js @@ -0,0 +1,9 @@ +/** + * Browser Image Compression + * v2.0.2 + * by Donald + * https://github.com/Donaldcwl/browser-image-compression + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).imageCompression=t()}(this,(function(){"use strict";function _mergeNamespaces(e,t){return t.forEach((function(t){t&&"string"!=typeof t&&!Array.isArray(t)&&Object.keys(t).forEach((function(r){if("default"!==r&&!(r in e)){var i=Object.getOwnPropertyDescriptor(t,r);Object.defineProperty(e,r,i.get?i:{enumerable:!0,get:function(){return t[r]}})}}))})),Object.freeze(e)}function copyExifWithoutOrientation(e,t){return new Promise((function(r,i){let o;return getApp1Segment(e).then((function(e){try{return o=e,r(new Blob([t.slice(0,2),o,t.slice(2)],{type:"image/jpeg"}))}catch(e){return i(e)}}),i)}))}const getApp1Segment=e=>new Promise(((t,r)=>{const i=new FileReader;i.addEventListener("load",(({target:{result:e}})=>{const i=new DataView(e);let o=0;if(65496!==i.getUint16(o))return r("not a valid JPEG");for(o+=2;;){const a=i.getUint16(o);if(65498===a)break;const s=i.getUint16(o+2);if(65505===a&&1165519206===i.getUint32(o+4)){const a=o+10;let f;switch(i.getUint16(a)){case 18761:f=!0;break;case 19789:f=!1;break;default:return r("TIFF header contains invalid endian")}if(42!==i.getUint16(a+2,f))return r("TIFF header contains invalid version");const l=i.getUint32(a+4,f),c=a+l+2+12*i.getUint16(a+l,f);for(let e=a+l+2;e>>24&255,i[r+1]=o>>>16&255,i[r+2]=o>>>8&255,i[r+3]=o>>>0&255,new Uint8Array(i.buffer,0,r+4)},UZIP.deflateRaw=function(e,t){null==t&&(t={level:6});var r=new Uint8Array(50+Math.floor(1.1*e.length)),i=UZIP.F.deflateRaw(e,r,i,t.level);return new Uint8Array(r.buffer,0,i)},UZIP.encode=function(e,t){null==t&&(t=!1);var r=0,i=UZIP.bin.writeUint,o=UZIP.bin.writeUshort,a={};for(var s in e){var f=!UZIP._noNeed(s)&&!t,l=e[s],c=UZIP.crc.crc(l,0,l.length);a[s]={cpr:f,usize:l.length,crc:c,file:f?UZIP.deflateRaw(l):l}}for(var s in a)r+=a[s].file.length+30+46+2*UZIP.bin.sizeUTF8(s);r+=22;var u=new Uint8Array(r),h=0,d=[];for(var s in a){var A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,0)}var g=0,p=h;for(var s in a){A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,1,d[g++])}var m=h-p;return i(u,h,101010256),h+=4,o(u,h+=4,g),o(u,h+=2,g),i(u,h+=2,m),i(u,h+=4,p),h+=4,h+=2,u.buffer},UZIP._noNeed=function(e){var t=e.split(".").pop().toLowerCase();return-1!="png,jpg,jpeg,zip".indexOf(t)},UZIP._writeHeader=function(e,t,r,i,o,a){var s=UZIP.bin.writeUint,f=UZIP.bin.writeUshort,l=i.file;return s(e,t,0==o?67324752:33639248),t+=4,1==o&&(t+=2),f(e,t,20),f(e,t+=2,0),f(e,t+=2,i.cpr?8:0),s(e,t+=2,0),s(e,t+=4,i.crc),s(e,t+=4,l.length),s(e,t+=4,i.usize),f(e,t+=4,UZIP.bin.sizeUTF8(r)),f(e,t+=2,0),t+=2,1==o&&(t+=2,t+=2,s(e,t+=6,a),t+=4),t+=UZIP.bin.writeUTF8(e,t,r),0==o&&(e.set(l,t),t+=l.length),t},UZIP.crc={table:function(){for(var e=new Uint32Array(256),t=0;t<256;t++){for(var r=t,i=0;i<8;i++)1&r?r=3988292384^r>>>1:r>>>=1;e[t]=r}return e}(),update:function(e,t,r,i){for(var o=0;o>>8;return e},crc:function(e,t,r){return 4294967295^UZIP.crc.update(4294967295,e,t,r)}},UZIP.adler=function(e,t,r){for(var i=1,o=0,a=t,s=t+r;a>8&255},readUint:function(e,t){return 16777216*e[t+3]+(e[t+2]<<16|e[t+1]<<8|e[t])},writeUint:function(e,t,r){e[t]=255&r,e[t+1]=r>>8&255,e[t+2]=r>>16&255,e[t+3]=r>>24&255},readASCII:function(e,t,r){for(var i="",o=0;o>6,e[t+o+1]=128|s>>0&63,o+=2;else if(0==(4294901760&s))e[t+o]=224|s>>12,e[t+o+1]=128|s>>6&63,e[t+o+2]=128|s>>0&63,o+=3;else{if(0!=(4292870144&s))throw"e";e[t+o]=240|s>>18,e[t+o+1]=128|s>>12&63,e[t+o+2]=128|s>>6&63,e[t+o+3]=128|s>>0&63,o+=4}}return o},sizeUTF8:function(e){for(var t=e.length,r=0,i=0;i>>3}var d=a.lits,A=a.strt,g=a.prev,p=0,m=0,w=0,v=0,b=0,y=0;for(h>2&&(A[y=UZIP.F._hash(e,0)]=0),l=0;l14e3||m>26697)&&h-l>100&&(u>>16,B=65535&F;if(0!=F){B=65535&F;var U=s(_=F>>>16,a.of0);a.lhst[257+U]++;var C=s(B,a.df0);a.dhst[C]++,v+=a.exb[U]+a.dxb[C],d[p]=_<<23|l-u,d[p+1]=B<<16|U<<8|C,p+=2,u=l+_}else a.lhst[e[l]]++;m++}}for(w==l&&0!=e.length||(u>>3},UZIP.F._bestMatch=function(e,t,r,i,o,a){var s=32767&t,f=r[s],l=s-f+32768&32767;if(f==s||i!=UZIP.F._hash(e,t-l))return 0;for(var c=0,u=0,h=Math.min(32767,t);l<=h&&0!=--a&&f!=s;){if(0==c||e[t+c]==e[t+c-l]){var d=UZIP.F._howLong(e,t,l);if(d>c){if(u=l,(c=d)>=o)break;l+2A&&(A=m,f=p)}}}l+=(s=f)-(f=r[s])+32768&32767}return c<<16|u},UZIP.F._howLong=function(e,t,r){if(e[t]!=e[t-r]||e[t+1]!=e[t+1-r]||e[t+2]!=e[t+2-r])return 0;var i=t,o=Math.min(e.length,t+258);for(t+=3;t>>23,R=M+(8388607&T);M>16,H=O>>8&255,L=255&O;y(f,l=UZIP.F._writeLit(257+H,C,f,l),S-v.of0[H]),l+=v.exb[H],b(f,l=UZIP.F._writeLit(L,I,f,l),P-v.df0[L]),l+=v.dxb[L],M+=S}}l=UZIP.F._writeLit(256,C,f,l)}return l},UZIP.F._copyExact=function(e,t,r,i,o){var a=o>>>3;return i[a]=r,i[a+1]=r>>>8,i[a+2]=255-i[a],i[a+3]=255-i[a+1],a+=4,i.set(new Uint8Array(e.buffer,t,r),a),o+(r+4<<3)},UZIP.F.getTrees=function(){for(var e=UZIP.F.U,t=UZIP.F._hufTree(e.lhst,e.ltree,15),r=UZIP.F._hufTree(e.dhst,e.dtree,15),i=[],o=UZIP.F._lenCodes(e.ltree,i),a=[],s=UZIP.F._lenCodes(e.dtree,a),f=0;f4&&0==e.itree[1+(e.ordr[c-1]<<1)];)c--;return[t,r,l,o,s,c,i,a]},UZIP.F.getSecond=function(e){for(var t=[],r=0;r>1)+",");return t},UZIP.F.contSize=function(e,t){for(var r=0,i=0;i15&&(UZIP.F._putsE(r,i,s,f),i+=f)}return i},UZIP.F._lenCodes=function(e,t){for(var r=e.length;2!=r&&0==e[r-1];)r-=2;for(var i=0;i>>1,138))<11?t.push(17,c-3):t.push(18,c-11),i+=2*c-2}else if(o==f&&a==o&&s==o){for(l=i+5;l+2>>1,6);t.push(16,c-3),i+=2*c-2}else t.push(o,0)}return r>>>1},UZIP.F._hufTree=function(e,t,r){var i=[],o=e.length,a=t.length,s=0;for(s=0;sr&&(UZIP.F.restrictDepth(l,r,p),p=r),s=0;st;i++){var s=e[i].d;e[i].d=t,a+=o-(1<>>=r-t;a>0;){(s=e[i].d)=0;i--)e[i].d==t&&a<0&&(e[i].d--,a++);0!=a&&console.log("debt left")},UZIP.F._goodIndex=function(e,t){var r=0;return t[16|r]<=e&&(r|=16),t[8|r]<=e&&(r|=8),t[4|r]<=e&&(r|=4),t[2|r]<=e&&(r|=2),t[1|r]<=e&&(r|=1),r},UZIP.F._writeLit=function(e,t,r,i){return UZIP.F._putsF(r,i,t[e<<1]),i+t[1+(e<<1)]},UZIP.F.inflate=function(e,t){var r=Uint8Array;if(3==e[0]&&0==e[1])return t||new r(0);var i=UZIP.F,o=i._bitsF,a=i._bitsE,s=i._decodeTiny,f=i.makeCodes,l=i.codes2map,c=i._get17,u=i.U,h=null==t;h&&(t=new r(e.length>>>2<<3));for(var d,A,g=0,p=0,m=0,w=0,v=0,b=0,y=0,E=0,F=0;0==g;)if(g=o(e,F,1),p=o(e,F+1,2),F+=3,0!=p){if(h&&(t=UZIP.F._check(t,E+(1<<17))),1==p&&(d=u.flmap,A=u.fdmap,b=511,y=31),2==p){m=a(e,F,5)+257,w=a(e,F+5,5)+1,v=a(e,F+10,4)+4,F+=14;for(var _=0;_<38;_+=2)u.itree[_]=0,u.itree[_+1]=0;var B=1;for(_=0;_B&&(B=U)}F+=3*v,f(u.itree,B),l(u.itree,B,u.imap),d=u.lmap,A=u.dmap,F=s(u.imap,(1<>>4;if(M>>>8==0)t[E++]=M;else{if(256==M)break;var x=E+M-254;if(M>264){var T=u.ldef[M-257];x=E+(T>>>3)+a(e,F,7&T),F+=7&T}var S=A[c(e,F)&y];F+=15&S;var R=S>>>4,O=u.ddef[R],P=(O>>>4)+o(e,F,15&O);for(F+=15&O,h&&(t=UZIP.F._check(t,E+(1<<17)));E>>3),L=e[H-4]|e[H-3]<<8;h&&(t=UZIP.F._check(t,E+L)),t.set(new r(e.buffer,e.byteOffset+H,L),E),F=H+L<<3,E+=L}return t.length==E?t:t.slice(0,E)},UZIP.F._check=function(e,t){var r=e.length;if(t<=r)return e;var i=new Uint8Array(Math.max(r<<1,t));return i.set(e,0),i},UZIP.F._decodeTiny=function(e,t,r,i,o,a){for(var s=UZIP.F._bitsE,f=UZIP.F._get17,l=0;l>>4;if(u<=15)a[l]=u,l++;else{var h=0,d=0;16==u?(d=3+s(i,o,2),o+=2,h=a[l-1]):17==u?(d=3+s(i,o,3),o+=3):18==u&&(d=11+s(i,o,7),o+=7);for(var A=l+d;l>>1;ao&&(o=f),a++}for(;a>1,f=e[a+1],l=s<<4|f,c=t-f,u=e[a]<>>15-t]=l,u++}},UZIP.F.revCodes=function(e,t){for(var r=UZIP.F.U.rev15,i=15-t,o=0;o>>i}},UZIP.F._putsE=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},UZIP.F._putsF=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},UZIP.F._bitsE=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},UZIP.F._get25=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},UZIP.F.U=(t=Uint16Array,r=Uint32Array,{next_code:new t(16),bl_count:new t(16),ordr:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],of0:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],exb:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],ldef:new t(32),df0:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],dxb:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],ddef:new r(32),flmap:new t(512),fltree:[],fdmap:new t(32),fdtree:[],lmap:new t(32768),ltree:[],ttree:[],dmap:new t(32768),dtree:[],imap:new t(512),itree:[],rev15:new t(32768),lhst:new r(286),dhst:new r(30),ihst:new r(19),lits:new r(15e3),strt:new t(65536),prev:new t(32768)}),function(){for(var e=UZIP.F.U,t=0;t<32768;t++){var r=t;r=(4278255360&(r=(4042322160&(r=(3435973836&(r=(2863311530&r)>>>1|(1431655765&r)<<1))>>>2|(858993459&r)<<2))>>>4|(252645135&r)<<4))>>>8|(16711935&r)<<8,e.rev15[t]=(r>>>16|r<<16)>>>17}function pushV(e,t,r){for(;0!=t--;)e.push(0,r)}for(t=0;t<32;t++)e.ldef[t]=e.of0[t]<<3|e.exb[t],e.ddef[t]=e.df0[t]<<4|e.dxb[t];pushV(e.fltree,144,8),pushV(e.fltree,112,9),pushV(e.fltree,24,7),pushV(e.fltree,8,8),UZIP.F.makeCodes(e.fltree,9),UZIP.F.codes2map(e.fltree,9,e.flmap),UZIP.F.revCodes(e.fltree,9),pushV(e.fdtree,32,5),UZIP.F.makeCodes(e.fdtree,5),UZIP.F.codes2map(e.fdtree,5,e.fdmap),UZIP.F.revCodes(e.fdtree,5),pushV(e.itree,19,0),pushV(e.ltree,286,0),pushV(e.dtree,30,0),pushV(e.ttree,320,0)}()}({get exports(){return e},set exports(t){e=t}});var UZIP=_mergeNamespaces({__proto__:null,default:e},[e]);const UPNG=function(){var e={nextZero(e,t){for(;0!=e[t];)t++;return t},readUshort:(e,t)=>e[t]<<8|e[t+1],writeUshort(e,t,r){e[t]=r>>8&255,e[t+1]=255&r},readUint:(e,t)=>16777216*e[t]+(e[t+1]<<16|e[t+2]<<8|e[t+3]),writeUint(e,t,r){e[t]=r>>24&255,e[t+1]=r>>16&255,e[t+2]=r>>8&255,e[t+3]=255&r},readASCII(e,t,r){let i="";for(let o=0;oe.length<2?`0${e}`:e,readUTF8(t,r,i){let o,a="";for(let o=0;o>3)]>>7-((7&A)<<0)&1);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>2)]>>6-((3&A)<<1)&3);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>1)]>>4-((1&A)<<2)&15);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>>3)]>>>7-(7&B)&1))==255*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(2==h)for(B=0;B>>2)]>>>6-((3&B)<<1)&3))==85*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(4==h)for(B=0;B>>1)]>>>4-((1&B)<<2)&15))==17*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(8==h)for(B=0;B>3,s=Math.ceil(r*o/8),f=new Uint8Array(i*s);let l=0;const c=[0,0,4,0,2,0,1],u=[0,4,0,2,0,1,0],h=[8,8,8,4,4,2,2],d=[8,8,4,4,2,2,1];let A=0;for(;A<7;){const p=h[A],m=d[A];let w=0,v=0,b=c[A];for(;b>3])>>7-(7&i)&1,f[_*s+(t>>3)]|=g<<7-((7&t)<<0);if(2==o)g=(g=e[i>>3])>>6-(7&i)&3,f[_*s+(t>>2)]|=g<<6-((3&t)<<1);if(4==o)g=(g=e[i>>3])>>4-(7&i)&15,f[_*s+(t>>1)]|=g<<4-((1&t)<<2);if(o>=8){const r=_*s+t*a;for(let t=0;t>3)+t]}i+=o,t+=m}F++,_+=p}w*v!=0&&(l+=v*(1+E)),A+=1}return f}(r,e)),r}function _inflate(e,r){return t(new Uint8Array(e.buffer,2,e.length-6),r)}var t=function(){const e={H:{}};return e.H.N=function(t,r){const i=Uint8Array;let o,a,s=0,f=0,l=0,c=0,u=0,h=0,d=0,A=0,g=0;if(3==t[0]&&0==t[1])return r||new i(0);const p=e.H,m=p.b,w=p.e,v=p.R,b=p.n,y=p.A,E=p.Z,F=p.m,_=null==r;for(_&&(r=new i(t.length>>>2<<5));0==s;)if(s=m(t,g,1),f=m(t,g+1,2),g+=3,0!=f){if(_&&(r=e.H.W(r,A+(1<<17))),1==f&&(o=F.J,a=F.h,h=511,d=31),2==f){l=w(t,g,5)+257,c=w(t,g+5,5)+1,u=w(t,g+10,4)+4,g+=14;let e=1;for(var B=0;B<38;B+=2)F.Q[B]=0,F.Q[B+1]=0;for(B=0;Be&&(e=r)}g+=3*u,b(F.Q,e),y(F.Q,e,F.u),o=F.w,a=F.d,g=v(F.u,(1<>>4;if(i>>>8==0)r[A++]=i;else{if(256==i)break;{let e=A+i-254;if(i>264){const r=F.q[i-257];e=A+(r>>>3)+w(t,g,7&r),g+=7&r}const o=a[E(t,g)&d];g+=15&o;const s=o>>>4,f=F.c[s],l=(f>>>4)+m(t,g,15&f);for(g+=15&f;A>>3),a=t[o-4]|t[o-3]<<8;_&&(r=e.H.W(r,A+a)),r.set(new i(t.buffer,t.byteOffset+o,a),A),g=o+a<<3,A+=a}return r.length==A?r:r.slice(0,A)},e.H.W=function(e,t){const r=e.length;if(t<=r)return e;const i=new Uint8Array(r<<1);return i.set(e,0),i},e.H.R=function(t,r,i,o,a,s){const f=e.H.e,l=e.H.Z;let c=0;for(;c>>4;if(i<=15)s[c]=i,c++;else{let e=0,t=0;16==i?(t=3+f(o,a,2),a+=2,e=s[c-1]):17==i?(t=3+f(o,a,3),a+=3):18==i&&(t=11+f(o,a,7),a+=7);const r=c+t;for(;c>>1;for(;ao&&(o=r),a++}for(;a>1,s=t[e+1],f=o<<4|s,l=r-s;let c=t[e]<>>15-r]=f,c++}}},e.H.l=function(t,r){const i=e.H.m.r,o=15-r;for(let e=0;e>>o}},e.H.M=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},e.H.I=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},e.H.e=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},e.H.i=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},e.H.m=function(){const e=Uint16Array,t=Uint32Array;return{K:new e(16),j:new e(16),X:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],S:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],T:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],q:new e(32),p:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],z:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],c:new t(32),J:new e(512),_:[],h:new e(32),$:[],w:new e(32768),C:[],v:[],d:new e(32768),D:[],u:new e(512),Q:[],r:new e(32768),s:new t(286),Y:new t(30),a:new t(19),t:new t(15e3),k:new e(65536),g:new e(32768)}}(),function(){const t=e.H.m;for(var r=0;r<32768;r++){let e=r;e=(2863311530&e)>>>1|(1431655765&e)<<1,e=(3435973836&e)>>>2|(858993459&e)<<2,e=(4042322160&e)>>>4|(252645135&e)<<4,e=(4278255360&e)>>>8|(16711935&e)<<8,t.r[r]=(e>>>16|e<<16)>>>17}function n(e,t,r){for(;0!=t--;)e.push(0,r)}for(r=0;r<32;r++)t.q[r]=t.S[r]<<3|t.T[r],t.c[r]=t.p[r]<<4|t.z[r];n(t._,144,8),n(t._,112,9),n(t._,24,7),n(t._,8,8),e.H.n(t._,9),e.H.A(t._,9,t.J),e.H.l(t._,9),n(t.$,32,5),e.H.n(t.$,5),e.H.A(t.$,5,t.h),e.H.l(t.$,5),n(t.Q,19,0),n(t.C,286,0),n(t.D,30,0),n(t.v,320,0)}(),e.H.N}();function _getBPP(e){return[1,null,3,1,2,null,4][e.ctype]*e.depth}function _filterZero(e,t,r,i,o){let a=_getBPP(t);const s=Math.ceil(i*a/8);let f,l;a=Math.ceil(a/8);let c=e[r],u=0;if(c>1&&(e[r]=[0,0,1][c-2]),3==c)for(u=a;u>>1)&255;for(let t=0;t>>1);for(;u>>1)}else{for(;u=0&&f>=0?(h=r*t+a<<2,d=(f+r)*o+s+a<<2):(h=(-f+r)*t-s+a<<2,d=r*o+a<<2),0==l)i[d]=e[h],i[d+1]=e[h+1],i[d+2]=e[h+2],i[d+3]=e[h+3];else if(1==l){var A=e[h+3]*(1/255),g=e[h]*A,p=e[h+1]*A,m=e[h+2]*A,w=i[d+3]*(1/255),v=i[d]*w,b=i[d+1]*w,y=i[d+2]*w;const t=1-A,r=A+w*t,o=0==r?0:1/r;i[d+3]=255*r,i[d+0]=(g+v*t)*o,i[d+1]=(p+b*t)*o,i[d+2]=(m+y*t)*o}else if(2==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];A==w&&g==v&&p==b&&m==y?(i[d]=0,i[d+1]=0,i[d+2]=0,i[d+3]=0):(i[d]=g,i[d+1]=p,i[d+2]=m,i[d+3]=A)}else if(3==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];if(A==w&&g==v&&p==b&&m==y)continue;if(A<220&&w>20)return!1}return!0}return{decode:function decode(r){const i=new Uint8Array(r);let o=8;const a=e,s=a.readUshort,f=a.readUint,l={tabs:{},frames:[]},c=new Uint8Array(i.length);let u,h=0,d=0;const A=[137,80,78,71,13,10,26,10];for(var g=0;g<8;g++)if(i[g]!=A[g])throw"The input is not a PNG file!";for(;o>>1:r>>>=1;e[t]=r}return e}(),update(e,t,r,o){for(let a=0;a>>8;return e},crc:(e,t,r)=>4294967295^i.update(4294967295,e,t,r)};function addErr(e,t,r,i){t[r]+=e[0]*i>>4,t[r+1]+=e[1]*i>>4,t[r+2]+=e[2]*i>>4,t[r+3]+=e[3]*i>>4}function N(e){return Math.max(0,Math.min(255,e))}function D(e,t){const r=e[0]-t[0],i=e[1]-t[1],o=e[2]-t[2],a=e[3]-t[3];return r*r+i*i+o*o+a*a}function dither(e,t,r,i,o,a,s){null==s&&(s=1);const f=i.length,l=[];for(var c=0;c>>0&255,e>>>8&255,e>>>16&255,e>>>24&255])}for(c=0;c>2]=u,A[c>>2]=i[u]}}function _main(e,r,o,a,s){null==s&&(s={});const{crc:f}=i,l=t.writeUint,c=t.writeUshort,u=t.writeASCII;let h=8;const d=e.frames.length>1;let A,g=!1,p=33+(d?20:0);if(null!=s.sRGB&&(p+=13),null!=s.pHYs&&(p+=21),null!=s.iCCP&&(A=pako.deflate(s.iCCP),p+=21+A.length+4),3==e.ctype){for(var m=e.plte.length,w=0;w>>24!=255&&(g=!0);p+=8+3*m+4+(g?8+1*m+4:0)}for(var v=0;v>>8&255,a=r>>>16&255;b[h+t+0]=i,b[h+t+1]=o,b[h+t+2]=a}if(h+=3*m,l(b,h,f(b,h-3*m-4,3*m+4)),h+=4,g){l(b,h,m),h+=4,u(b,h,"tRNS"),h+=4;for(w=0;w>>24&255;h+=m,l(b,h,f(b,h-m-4,m+4)),h+=4}}let E=0;for(v=0;vc&&(c=t),eh&&(h=e))}-1==c&&(s=f=c=h=0),a&&(1==(1&s)&&s--,1==(1&f)&&f--);const v=(c-s+1)*(h-f+1);v>2,e>>2);F.push(_);const t=new Uint8Array(r.abuf,i,e);h&&dither(B.img,B.rect.width,B.rect.height,E,t,_),B.img.set(t),i+=e}}else for(p=0;pU&&t==e[w-U])_[w]=_[w-U];else{let e=y[t];if(null==e&&(y[t]=e=E.length,E.push(t),E.length>=300))break;_[w]=e}}}const C=E.length;C<=256&&0==u&&(A=C<=2?1:C<=4?2:C<=16?4:8,A=Math.max(A,c));for(p=0;p>1)]|=o[e+Q]<<4-4*(1&Q);else if(2==A)for(Q=0;Q>2)]|=o[e+Q]<<6-2*(3&Q);else if(1==A)for(Q=0;Q>3)]|=o[e+Q]<<7-1*(7&Q)}t=I,d=3,i=1}else if(0==v&&1==b.length){I=new Uint8Array(U*e*3);const o=U*e;for(w=0;ww&&(w=i),fv&&(v=f))}-1==w&&(p=m=w=v=0),f&&(1==(1&p)&&p--,1==(1&m)&&m--),s={x:p,y:m,width:w-p+1,height:v-m+1};const b=o[a];b.rect=s,b.blend=1,b.img=new Uint8Array(s.width*s.height*4),0==o[a-1].dispose?(e(u,r,i,b.img,s.width,s.height,-s.x,-s.y,0),_prepareDiff(A,r,i,b.img,s)):e(A,r,i,b.img,s.width,s.height,-s.x,-s.y,0)}function _prepareDiff(t,r,i,o,a){e(t,r,i,o,a.width,a.height,-a.x,-a.y,2)}function _filterZero(e,t,r,i,o,a,s){const f=[];let l,c=[0,1,2,3,4];-1!=a?c=[a]:(t*i>5e5||1==r)&&(c=[0]),s&&(l={level:0});const u=UZIP;for(var h=0;h>1)+256&255;if(4==s)for(c=a;c>1)&255;for(c=a;c>1)&255}if(4==s){for(c=0;c>2);let u;if(r.length<2e7)for(var h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}else for(h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}return{abuf:i.buffer,inds:c,plte:f}}function getKDtree(e,t,r){null==r&&(r=1e-4);const i=new Uint32Array(e.buffer),o={i0:0,i1:e.length,bst:null,est:null,tdst:0,left:null,right:null};o.bst=stats(e,o.i0,o.i1),o.est=estats(o.bst);const a=[o];for(;a.lengtht&&(t=a[s].est.L,o=s);if(t=l||f.i1<=l){f.est.L=0;continue}const c={i0:f.i0,i1:l,bst:null,est:null,tdst:0,left:null,right:null};c.bst=stats(e,c.i0,c.i1),c.est=estats(c.bst);const u={i0:l,i1:f.i1,bst:null,est:null,tdst:0,left:null,right:null};u.bst={R:[],m:[],N:f.bst.N-c.bst.N};for(s=0;s<16;s++)u.bst.R[s]=f.bst.R[s]-c.bst.R[s];for(s=0;s<4;s++)u.bst.m[s]=f.bst.m[s]-c.bst.m[s];u.est=estats(u.bst),f.left=c,f.right=u,a[o]=c,a.push(u)}a.sort(((e,t)=>t.bst.N-e.bst.N));for(s=0;s0&&(s=e.right,f=e.left);const l=getNearest(s,t,r,i,o);if(l.tdst<=a*a)return l;const c=getNearest(f,t,r,i,o);return c.tdsta;)i-=4;if(r>=i)break;const s=t[r>>2];t[r>>2]=t[i>>2],t[i>>2]=s,r+=4,i-=4}for(;vecDot(e,r,o)>a;)r-=4;return r+4}function vecDot(e,t,r){return e[t]*r[0]+e[t+1]*r[1]+e[t+2]*r[2]+e[t+3]*r[3]}function stats(e,t,r){const i=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],o=[0,0,0,0],a=r-t>>2;for(let a=t;a>>0}}var o={multVec:(e,t)=>[e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],e[4]*t[0]+e[5]*t[1]+e[6]*t[2]+e[7]*t[3],e[8]*t[0]+e[9]*t[1]+e[10]*t[2]+e[11]*t[3],e[12]*t[0]+e[13]*t[1]+e[14]*t[2]+e[15]*t[3]],dot:(e,t)=>e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],sml:(e,t)=>[e*t[0],e*t[1],e*t[2],e*t[3]]};UPNG.encode=function encode(e,t,r,i,o,a,s){null==i&&(i=0),null==s&&(s=!1);const f=compress(e,t,r,i,[!1,!1,!1,0,s,!1]);return compressPNG(f,-1),_main(f,t,r,o,a)},UPNG.encodeLL=function encodeLL(e,t,r,i,o,a,s,f){const l={ctype:0+(1==i?0:2)+(0==o?0:4),depth:a,frames:[]},c=(i+o)*a,u=c*t;for(let i=0;i>>0),set16(1),set16(32),set32(3),set32(c),set32(2835),set32(2835),seek(8),set32(16711680),set32(65280),set32(255),set32(4278190080),set32(1466527264),function convert(){for(;b0;){for(w=122+b*l,g=0;g>>24,d.setUint32(w+g,p<<8|m),g+=4;b++}E{t(new Blob([e],{type:"image/bmp"}))}))},_dly:9};var r={CHROME:"CHROME",FIREFOX:"FIREFOX",DESKTOP_SAFARI:"DESKTOP_SAFARI",IE:"IE",IOS:"IOS",ETC:"ETC"},i={[r.CHROME]:16384,[r.FIREFOX]:11180,[r.DESKTOP_SAFARI]:16384,[r.IE]:8192,[r.IOS]:4096,[r.ETC]:8192};const o="undefined"!=typeof window,a="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,s=o&&window.cordova&&window.cordova.require&&window.cordova.require("cordova/modulemapper"),CustomFile=(o||a)&&(s&&s.getOriginalSymbol(window,"File")||"undefined"!=typeof File&&File),CustomFileReader=(o||a)&&(s&&s.getOriginalSymbol(window,"FileReader")||"undefined"!=typeof FileReader&&FileReader);function getFilefromDataUrl(e,t,r=Date.now()){return new Promise((i=>{const o=e.split(","),a=o[0].match(/:(.*?);/)[1],s=globalThis.atob(o[1]);let f=s.length;const l=new Uint8Array(f);for(;f--;)l[f]=s.charCodeAt(f);const c=new Blob([l],{type:a});c.name=t,c.lastModified=r,i(c)}))}function getDataUrlFromFile(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=()=>t(i.result),i.onerror=e=>r(e),i.readAsDataURL(e)}))}function loadImage(e){return new Promise(((t,r)=>{const i=new Image;i.onload=()=>t(i),i.onerror=e=>r(e),i.src=e}))}function getBrowserName(){if(void 0!==getBrowserName.cachedResult)return getBrowserName.cachedResult;let e=r.ETC;const{userAgent:t}=navigator;return/Chrom(e|ium)/i.test(t)?e=r.CHROME:/iP(ad|od|hone)/i.test(t)&&/WebKit/i.test(t)?e=r.IOS:/Safari/i.test(t)?e=r.DESKTOP_SAFARI:/Firefox/i.test(t)?e=r.FIREFOX:(/MSIE/i.test(t)||!0==!!document.documentMode)&&(e=r.IE),getBrowserName.cachedResult=e,getBrowserName.cachedResult}function approximateBelowMaximumCanvasSizeOfBrowser(e,t){const r=getBrowserName(),o=i[r];let a=e,s=t,f=a*s;const l=a>s?s/a:a/s;for(;f>o*o;){const e=(o+a)/2,t=(o+s)/2;et.toBlob(e,r))).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_5.call(this)}catch(e){return f(e)}}.bind(this),f);{if("function"==typeof OffscreenCanvas&&e instanceof OffscreenCanvas)return e.convertToBlob({type:r,quality:a}).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f);{let d;return d=e.toDataURL(r,a),getFilefromDataUrl(d,i,o).then(function(e){try{return l=e,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f)}function $If_6(){return $If_5.call(this)}}function $If_5(){return $If_4.call(this)}}function $If_4(){return s(l)}}))}function cleanupCanvasMemory(e){e.width=0,e.height=0}function isAutoOrientationInBrowser(){return new Promise((function(e,t){let r,i,o,a,s;return void 0!==isAutoOrientationInBrowser.cachedResult?e(isAutoOrientationInBrowser.cachedResult):(r="data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==",getFilefromDataUrl("data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==","test.jpg",Date.now()).then((function(r){try{return i=r,drawFileInCanvas(i).then((function(r){try{return o=r[1],canvasToFile(o,i.type,i.name,i.lastModified).then((function(r){try{return a=r,cleanupCanvasMemory(o),drawFileInCanvas(a).then((function(r){try{return s=r[0],isAutoOrientationInBrowser.cachedResult=1===s.width&&2===s.height,e(isAutoOrientationInBrowser.cachedResult)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t))}))}function getExifOrientation(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=e=>{const r=new DataView(e.target.result);if(65496!=r.getUint16(0,!1))return t(-2);const i=r.byteLength;let o=2;for(;or(e),i.readAsArrayBuffer(e)}))}function handleMaxWidthOrHeight(e,t){const{width:r}=e,{height:i}=e,{maxWidthOrHeight:o}=t;let a,s=e;return isFinite(o)&&(r>o||i>o)&&([s,a]=getNewCanvasAndCtx(r,i),r>i?(s.width=o,s.height=i/r*o):(s.width=r/i*o,s.height=o),a.drawImage(e,0,0,s.width,s.height),cleanupCanvasMemory(e)),s}function followExifOrientation(e,t){const{width:r}=e,{height:i}=e,[o,a]=getNewCanvasAndCtx(r,i);switch(t>4&&t<9?(o.width=i,o.height=r):(o.width=r,o.height=i),t){case 2:a.transform(-1,0,0,1,r,0);break;case 3:a.transform(-1,0,0,-1,r,i);break;case 4:a.transform(1,0,0,-1,0,i);break;case 5:a.transform(0,1,1,0,0,0);break;case 6:a.transform(0,1,-1,0,i,0);break;case 7:a.transform(0,-1,-1,0,i,r);break;case 8:a.transform(0,-1,1,0,0,r)}return a.drawImage(e,0,0,r,i),cleanupCanvasMemory(e),o}function compress(e,t,r=0){return new Promise((function(i,o){let a,s,f,l,c,u,h,d,A,g,p,m,w,v,b,y,E,F,_,B;function incProgress(e=5){if(t.signal&&t.signal.aborted)throw t.signal.reason;a+=e,t.onProgress(Math.min(a,100))}function setProgress(e){if(t.signal&&t.signal.aborted)throw t.signal.reason;a=Math.min(Math.max(e,a),100),t.onProgress(a)}return a=r,s=t.maxIteration||10,f=1024*t.maxSizeMB*1024,incProgress(),drawFileInCanvas(e,t).then(function(r){try{return[,l]=r,incProgress(),c=handleMaxWidthOrHeight(l,t),incProgress(),new Promise((function(r,i){var o;if(!(o=t.exifOrientation))return getExifOrientation(e).then(function(e){try{return o=e,$If_2.call(this)}catch(e){return i(e)}}.bind(this),i);function $If_2(){return r(o)}return $If_2.call(this)})).then(function(r){try{return u=r,incProgress(),isAutoOrientationInBrowser().then(function(r){try{return h=r?c:followExifOrientation(c,u),incProgress(),d=t.initialQuality||1,A=t.fileType||e.type,canvasToFile(h,A,e.name,e.lastModified,d).then(function(r){try{{if(g=r,incProgress(),p=g.size>f,m=g.size>e.size,!p&&!m)return setProgress(100),i(g);var a;function $Loop_3(){if(s--&&(b>f||b>w)){let t,r;return t=B?.95*_.width:_.width,r=B?.95*_.height:_.height,[E,F]=getNewCanvasAndCtx(t,r),F.drawImage(_,0,0,t,r),d*="image/png"===A?.85:.95,canvasToFile(E,A,e.name,e.lastModified,d).then((function(e){try{return y=e,cleanupCanvasMemory(_),_=E,b=y.size,setProgress(Math.min(99,Math.floor((v-b)/(v-f)*100))),$Loop_3}catch(e){return o(e)}}),o)}return[1]}return w=e.size,v=g.size,b=v,_=h,B=!t.alwaysKeepResolution&&p,(a=function(e){for(;e;){if(e.then)return void e.then(a,o);try{if(e.pop){if(e.length)return e.pop()?$Loop_3_exit.call(this):e;e=$Loop_3}else e=e.call(this)}catch(e){return o(e)}}}.bind(this))($Loop_3);function $Loop_3_exit(){return cleanupCanvasMemory(_),cleanupCanvasMemory(E),cleanupCanvasMemory(c),cleanupCanvasMemory(h),cleanupCanvasMemory(l),setProgress(100),i(y)}}}catch(u){return o(u)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}))}const f="\nlet scriptImported = false\nself.addEventListener('message', async (e) => {\n const { file, id, imageCompressionLibUrl, options } = e.data\n options.onProgress = (progress) => self.postMessage({ progress, id })\n try {\n if (!scriptImported) {\n // console.log('[worker] importScripts', imageCompressionLibUrl)\n self.importScripts(imageCompressionLibUrl)\n scriptImported = true\n }\n // console.log('[worker] self', self)\n const compressedFile = await imageCompression(file, options)\n self.postMessage({ file: compressedFile, id })\n } catch (e) {\n // console.error('[worker] error', e)\n self.postMessage({ error: e.message + '\\n' + e.stack, id })\n }\n})\n";let l;function compressOnWebWorker(e,t){return new Promise(((r,i)=>{l||(l=function createWorkerScriptURL(e){const t=[];return"function"==typeof e?t.push(`(${e})()`):t.push(e),URL.createObjectURL(new Blob(t))}(f));const o=new Worker(l);o.addEventListener("message",(function handler(e){if(t.signal&&t.signal.aborted)o.terminate();else if(void 0===e.data.progress){if(e.data.error)return i(new Error(e.data.error)),void o.terminate();r(e.data.file),o.terminate()}else t.onProgress(e.data.progress)})),o.addEventListener("error",i),t.signal&&t.signal.addEventListener("abort",(()=>{i(t.signal.reason),o.terminate()})),o.postMessage({file:e,imageCompressionLibUrl:t.libURL,options:{...t,onProgress:void 0,signal:void 0}})}))}function imageCompression(e,t){return new Promise((function(r,i){let o,a,s,f,l,c;if(o={...t},s=0,({onProgress:f}=o),o.maxSizeMB=o.maxSizeMB||Number.POSITIVE_INFINITY,l="boolean"!=typeof o.useWebWorker||o.useWebWorker,delete o.useWebWorker,o.onProgress=e=>{s=e,"function"==typeof f&&f(s)},!(e instanceof Blob||e instanceof CustomFile))return i(new Error("The file given is not an instance of Blob or File"));if(!/^image/.test(e.type))return i(new Error("The file given is not an image"));if(c="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,!l||"function"!=typeof Worker||c)return compress(e,o).then(function(e){try{return a=e,$If_4.call(this)}catch(e){return i(e)}}.bind(this),i);var u=function(){try{return $If_4.call(this)}catch(e){return i(e)}}.bind(this),$Try_1_Catch=function(t){try{return compress(e,o).then((function(e){try{return a=e,u()}catch(e){return i(e)}}),i)}catch(e){return i(e)}};try{return o.libURL=o.libURL||"https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.js",compressOnWebWorker(e,o).then((function(e){try{return a=e,u()}catch(e){return $Try_1_Catch()}}),$Try_1_Catch)}catch(e){$Try_1_Catch()}function $If_4(){try{a.name=e.name,a.lastModified=e.lastModified}catch(e){}try{o.preserveExif&&"image/jpeg"===e.type&&(!o.fileType||o.fileType&&o.fileType===e.type)&&(a=copyExifWithoutOrientation(e,a))}catch(e){}return r(a)}}))}return imageCompression.getDataUrlFromFile=getDataUrlFromFile,imageCompression.getFilefromDataUrl=getFilefromDataUrl,imageCompression.loadImage=loadImage,imageCompression.drawImageInCanvas=drawImageInCanvas,imageCompression.drawFileInCanvas=drawFileInCanvas,imageCompression.canvasToFile=canvasToFile,imageCompression.getExifOrientation=getExifOrientation,imageCompression.handleMaxWidthOrHeight=handleMaxWidthOrHeight,imageCompression.followExifOrientation=followExifOrientation,imageCompression.cleanupCanvasMemory=cleanupCanvasMemory,imageCompression.isAutoOrientationInBrowser=isAutoOrientationInBrowser,imageCompression.approximateBelowMaximumCanvasSizeOfBrowser=approximateBelowMaximumCanvasSizeOfBrowser,imageCompression.copyExifWithoutOrientation=copyExifWithoutOrientation,imageCompression.getBrowserName=getBrowserName,imageCompression.version="2.0.2",imageCompression})); +//# sourceMappingURL=browser-image-compression.js.map diff --git a/public/sw.js b/public/sw.js index b6f5b07..a36e430 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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 = [ diff --git a/scripts/add-credits.sql b/scripts/add-credits.sql new file mode 100644 index 0000000..617a122 --- /dev/null +++ b/scripts/add-credits.sql @@ -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; diff --git a/src/app/actions/enrich-data.ts b/src/app/actions/enrich-data.ts new file mode 100644 index 0000000..5086354 --- /dev/null +++ b/src/app/actions/enrich-data.ts @@ -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.', + }; + } +} diff --git a/src/app/actions/scan-label.ts b/src/app/actions/scan-label.ts new file mode 100644 index 0000000..6dfa9d4 --- /dev/null +++ b/src/app/actions/scan-label.ts @@ -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 { + 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.', + }; + } +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 84c9dc4..361e95d 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 813c320..40013f7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -44,7 +44,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/src/app/page.tsx b/src/app/page.tsx index dbf7b00..00b8175 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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) { - console.error('Detailed fetch error:', err); - setFetchError(err.message || JSON.stringify(err)); + // 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()} /> ); diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index d936beb..07c7ea5 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -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(null); + const [validatedSessionId, setValidatedSessionId] = useState(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...'); - await db.pending_scans.add({ - temp_id: crypto.randomUUID(), - imageBase64: compressedBase64, - timestamp: Date.now(), - provider: aiProvider, - locale: locale - }); + // 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: 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 - }); - setIsQueued(true); - setError(null); // Clear error as we are queuing - } else { - setError(response.error || t('camera.analysisError')); + 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 + }; + }); + } + }) + .catch(err => console.error('[CameraCapture] Enrichment failed:', err)); } - } - } catch (err) { - 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')); + throw new Error(t('camera.analysisError')); } + } catch (err: any) { + console.error('Processing failed:', err); + 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 (

{t('camera.magicShot')}

- {isAdmin && (
- {!wbDiscovery && !isDiscovering && ( - )} - {isDiscovering && (
{t('camera.searchingWb')}
)} - {wbDiscovery && (
{t('camera.wbMatchFound')}
-

- {wbDiscovery.title} -

+

{wbDiscovery.title}

)} - - +
) : matchingBottle ? (
- + {t('camera.toVault')} - +
) : (
- {!previewUrl && !isProcessing && ( - )}
)} - {/* Status Messages */} {error && (
- - {error} + {error}
)} {isQueued && (
-
- -
+
Lokal gespeichert! Warteschlange aktiv
-

- Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet. -

+

Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.

)} {matchingBottle && !lastSavedId && (
-
- - {t('camera.alreadyInVault')} -
-

- {t('camera.alreadyInVaultDesc')} -

+
{t('camera.alreadyInVault')}
+

{t('camera.alreadyInVaultDesc')}

)} - {/* Analysis Results Display */} {previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
- - {t('camera.analysisSuccess')} + {t('camera.analysisSuccess')}
-
-
- - {t('camera.results')} -
+
{t('camera.results')}
-
- {t('bottle.nameLabel')}: - {analysisResult.name || '-'} -
-
- {t('bottle.distilleryLabel')}: - {analysisResult.distillery || '-'} -
-
- {t('bottle.categoryLabel')}: - {shortenCategory(analysisResult.category || '-')} -
-
- {t('bottle.abvLabel')}: - {analysisResult.abv ? `${analysisResult.abv}%` : '-'} -
- {analysisResult.age && ( -
- {t('bottle.ageLabel')}: - {analysisResult.age} {t('bottle.years')} -
- )} - {analysisResult.distilled_at && ( -
- {t('bottle.distilledLabel')}: - {analysisResult.distilled_at} -
- )} - {analysisResult.bottled_at && ( -
- {t('bottle.bottledLabel')}: - {analysisResult.bottled_at} -
- )} - {analysisResult.batch_info && ( -
- {t('bottle.batchLabel')}: - {analysisResult.batch_info} -
- )} - +
{t('bottle.nameLabel')}:{analysisResult.name || '-'}
+
{t('bottle.distilleryLabel')}:{analysisResult.distillery || '-'}
+
{t('bottle.categoryLabel')}:{shortenCategory(analysisResult.category || '-')}
+
{t('bottle.abvLabel')}:{analysisResult.abv ? `${analysisResult.abv}%` : '-'}
+ {analysisResult.age &&
{t('bottle.ageLabel')}:{analysisResult.age} {t('bottle.years')}
} + {analysisResult.vintage &&
Vintage:{analysisResult.vintage}
} + {analysisResult.bottler &&
Bottler:{analysisResult.bottler}
} + {analysisResult.distilled_at &&
{t('bottle.distilledLabel')}:{analysisResult.distilled_at}
} + {analysisResult.bottled_at &&
{t('bottle.bottledLabel')}:{analysisResult.bottled_at}
} + {analysisResult.batch_info &&
{t('bottle.batchLabel')}:{analysisResult.batch_info}
} + {analysisResult.bottleCode &&
Bottle Code:{analysisResult.bottleCode}
} {isAdmin && perfMetrics && (
-
- Performance Data -
+
Performance Data
-
- Comp: - {perfMetrics.compression.toFixed(0)}ms -
-
- AI: - {perfMetrics.ai.toFixed(0)}ms -
-
- Prep: - {perfMetrics.prep.toFixed(0)}ms -
-
- Total: - {(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms -
+
CLIENT:{perfMetrics.compression.toFixed(0)}ms
+
({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)
+ + {perfMetrics.cacheHit ? ( +
+ CACHE HIT +
+ ) : ( + <> +
+
AI BREAKDOWN:
+
+ {perfMetrics.imagePrep !== undefined &&
Prep:{perfMetrics.imagePrep.toFixed(0)}ms
} + {perfMetrics.encoding !== undefined &&
Encode:{perfMetrics.encoding.toFixed(0)}ms
} + {perfMetrics.modelInit !== undefined &&
Init:{perfMetrics.modelInit.toFixed(0)}ms
} +
API:{perfMetrics.aiApi.toFixed(0)}ms
+ {perfMetrics.validation !== undefined &&
Valid:{perfMetrics.validation.toFixed(0)}ms
} + {perfMetrics.dbOps !== undefined &&
DB:{perfMetrics.dbOps.toFixed(0)}ms
} +
+
TOTAL:{(perfMetrics.compression + perfMetrics.ai).toFixed(0)}ms
+
+ + )}
)} diff --git a/src/components/ScanAndTasteFlow.tsx b/src/components/ScanAndTasteFlow.tsx index f9e2993..02da7c2 100644 --- a/src/components/ScanAndTasteFlow.tsx +++ b/src/components/ScanAndTasteFlow.tsx @@ -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('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.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 { - console.error('[ScanFlow] magicScan failure:', result.error); - throw new Error(result.error || 'Flasche konnte nicht erkannt werden.'); + 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

AI Engine

- {perfMetrics.aiApi === 0 ? ( + {perfMetrics.cacheHit ? (

CACHE HIT

DB RESULTS

@@ -259,8 +473,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd <>

{perfMetrics.aiTotal.toFixed(0)}ms

- API: {perfMetrics.aiApi.toFixed(0)}ms - Parse: {perfMetrics.aiParse.toFixed(0)}ms + {perfMetrics.imagePrep !== undefined && Prep: {perfMetrics.imagePrep.toFixed(0)}ms} + {perfMetrics.encoding !== undefined && Encode: {perfMetrics.encoding.toFixed(0)}ms} + {perfMetrics.modelInit !== undefined && Init: {perfMetrics.modelInit.toFixed(0)}ms} + API: {perfMetrics.aiApi.toFixed(0)}ms + {perfMetrics.validation !== undefined && Valid: {perfMetrics.validation.toFixed(0)}ms} + {perfMetrics.dbOps !== undefined && DB: {perfMetrics.dbOps.toFixed(0)}ms}
)} @@ -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 && ( +
+
+
+

+ {aiFallbackActive + ? 'KI-Dienst nicht erreichbar. Nutze Platzhalter-Daten; Anreicherung erfolgt automatisch im Hintergrund.' + : 'Offline Modus - Daten werden hochgeladen wenn du online bist'} +

+
+
+ )} setIsSessionsOpen(true)} activeSessionName={activeSession?.name} activeSessionId={activeSession?.id} + isEnriching={isEnriching} /> {isAdmin && perfMetrics && (
@@ -326,12 +557,21 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
AI: - {perfMetrics.aiApi === 0 ? ( - CACHE HIT ⚡ + {perfMetrics.cacheHit ? ( + CACHE HIT ) : ( <> {perfMetrics.aiTotal.toFixed(0)}ms - (API: {perfMetrics.aiApi.toFixed(0)}ms / Pars: {perfMetrics.aiParse.toFixed(0)}ms) + + ( + {perfMetrics.imagePrep !== undefined && `Prep:${perfMetrics.imagePrep.toFixed(0)} `} + {perfMetrics.encoding !== undefined && `Enc:${perfMetrics.encoding.toFixed(0)} `} + {perfMetrics.modelInit !== undefined && `Init:${perfMetrics.modelInit.toFixed(0)} `} + API:{perfMetrics.aiApi.toFixed(0)} + {perfMetrics.validation !== undefined && ` Val:${perfMetrics.validation.toFixed(0)}`} + {perfMetrics.dbOps !== undefined && ` DB:${perfMetrics.dbOps.toFixed(0)}`} + ) + )}
diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index 324be14..c5c47b5 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -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 ( -
+
{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} - + )) ) : ( - Noch keine Tags gewählt... + Noch keine Tags gewählt... )}
{/* Search and Suggest */}
- + 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 && ( - + )}
{search && ( -
-
+
+
{filteredTags.length > 0 ? ( filteredTags.map(tag => ( )) ) : ( )} @@ -131,36 +131,43 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
{/* AI Suggestions */} - {!search && suggestedTagNames && suggestedTagNames.length > 0 && ( -
-
- {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'} + {!search && (isLoading || (suggestedTagNames && suggestedTagNames.length > 0)) && ( +
+
+ {isLoading ? : } + {isLoading ? 'Analysiere Aromen...' : 'KI Vorschläge'}
-
- {(tags || []) - .filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase())) - .map(tag => ( - - ))} +
+ {isLoading ? ( + [1, 2, 3].map(i => ( +
+ )) + ) : ( + (tags || []) + .filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames?.some((s: string) => s.toLowerCase() === t.name.toLowerCase())) + .map(tag => ( + + )) + )}
)} {/* AI Custom Suggestions */} {!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && ( -
-
+
+
Dominante Note anlegen?
-
+
{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 ? : } {name} @@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
)} - {/* Suggestions Chips (limit to 6 random or most common) */} + {/* Suggestions Chips */} {!search && (tags || []).length > 0 && (
{(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} diff --git a/src/components/TastingEditor.tsx b/src/components/TastingEditor.tsx index c4d52b2..bf33b36 100644 --- a/src/components/TastingEditor.tsx +++ b/src/components/TastingEditor.tsx @@ -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([]); const [palateTagIds, setPalateTagIds] = useState([]); const [finishTagIds, setFinishTagIds] = useState([]); + + // 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(null); const [textureTagIds, setTextureTagIds] = useState([]); const [selectedBuddyIds, setSelectedBuddyIds] = useState([]); @@ -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 (
- {/* Top Context Bar - Flex Child 1 */} -
- -
- - {/* Main Scrollable Content - Flex Child 2 */} + {/* Main Scrollable Content */}
{/* Palette Warning */} @@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes

- {bottleMetadata.distillery || 'Destillerie'} + {bottleDistillery || 'Destillerie'}

-

{bottleMetadata.name || 'Unbekannter Malt'}

+

{bottleName || 'Unbekannter Malt'}

- {bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `• ${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `• ${bottleMetadata.age}y` : ''} + {bottleCategory || 'Whisky'} {bottleAbv ? `• ${bottleAbv}%` : ''} {bottleAge ? `• ${bottleAge}y` : ''}

+ {/* Expandable Bottle Details */} +
+ + + {showBottleDetails && ( +
+ {/* Name */} +
+ + 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" + /> +
+ + {/* Distillery */} +
+ + 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" + /> +
+ +
+ {/* ABV */} +
+ + 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" + /> +
+ {/* Age */} +
+ + 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" + /> +
+
+ + {/* Category */} +
+ + 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" + /> +
+ {/* Vintage */} +
+ + 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" + /> +
+ + {/* Bottler */} +
+ + 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" + /> +
+ + {/* Distilled At */} +
+ + 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" + /> +
+ + {/* Bottled At */} +
+ + 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" + /> +
+ + {/* Batch Info */} +
+ + 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" + /> +
+ + {/* Bottle Code */} +
+ + 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" + /> +
+ + {/* Whiskybase Discovery */} + {isDiscoveringWb && ( +
+ + Searching Whiskybase... +
+ )} + + {whiskybaseDiscovery && ( +
+
+
+

+ Whiskybase Found +

+

+ {whiskybaseDiscovery.title} +

+ + View on Whiskybase → + +
+
+

+ ID: {whiskybaseDiscovery.id} +

+
+ )} + + {/* Whiskybase Error */} + {whiskybaseError && !whiskybaseDiscovery && !isDiscoveringWb && ( +
+

+ {whiskybaseError} +

+
+ )} +
+ )} +
+ + {/* Session Selector */} + + {/* Rating Slider */}
@@ -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} />
@@ -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} />
@@ -383,19 +685,19 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes )}
+
- {/* Sticky Footer - Flex Child 3 */} -
-
- -
+ {/* Fixed/Sticky Footer for Save Action */} +
+
+
diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx index 9e529d6..0ac72b9 100644 --- a/src/components/TastingNoteForm.tsx +++ b/src/components/TastingNoteForm.tsx @@ -439,18 +439,21 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
)} - + {/* Sticky Save Button Container */} +
+ +
); } diff --git a/src/components/UploadQueue.tsx b/src/components/UploadQueue.tsx index c4ce4a0..6c0ab0f 100644 --- a/src/components/UploadQueue.tsx +++ b/src/components/UploadQueue.tsx @@ -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,61 +57,139 @@ 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; - 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; + let bottleData; - // Reconcile pending tastings linked to this temp_id - if (item.temp_id) { - const linkedTastings = await db.pending_tastings - .where('pending_bottle_id') - .equals(item.temp_id) - .toArray(); + // 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); - for (const lt of linkedTastings) { - await db.pending_tastings.update(lt.id!, { - bottle_id: newBottleId, - pending_bottle_id: undefined - }); + 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 + }; } } - - setCompletedItems(prev => [...prev.slice(-4), { - id: itemId, - name: bottleData.name || 'Unbekannter Whisky', - bottleId: newBottleId, - type: 'scan' - }]); - await db.pending_scans.delete(item.id!); + } else { + throw new Error(ocrResult.error || 'Analyse fehlgeschlagen'); } - } else { - throw new Error(analysis.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; + + // Reconcile pending tastings linked to this temp_id + if (item.temp_id) { + const linkedTastings = await db.pending_tastings + .where('pending_bottle_id') + .equals(item.temp_id) + .toArray(); + + for (const lt of linkedTastings) { + await db.pending_tastings.update(lt.id!, { + bottle_id: newBottleId, + pending_bottle_id: undefined + }); + } + } + + setCompletedItems(prev => [...prev.slice(-4), { + id: itemId, + name: bottleData.name || 'Unbekannter Whisky', + bottleId: newBottleId, + type: 'scan' + }]); + await db.pending_scans.delete(item.id!); } } 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; diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts index c4f6982..3177ec8 100644 --- a/src/lib/ai-prompts.ts +++ b/src/lib/ai-prompts.ts @@ -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 `; diff --git a/src/lib/db.ts b/src/lib/db.ts index 39e02f6..169de1f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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', diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 8a8515e..34f6d7e 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -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', diff --git a/src/proxy.ts b/src/proxy.ts index ee07741..11c1e39 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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}`); } diff --git a/src/services/analyze-bottle-mistral.ts b/src/services/analyze-bottle-mistral.ts index b018803..e76765e 100644 --- a/src/services/analyze-bottle-mistral.ts +++ b/src/services/analyze-bottle-mistral.ts @@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale); - const startApi = performance.now(); - const chatResponse = await client.chat.complete({ - model: 'mistral-large-latest', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: prompt }, - { type: 'image_url', imageUrl: dataUrl } - ] - } - ], - responseFormat: { type: 'json_object' }, - temperature: 0.1 - }); - const endApi = performance.now(); - - const startParse = performance.now(); - const rawContent = chatResponse.choices?.[0].message.content; - if (!rawContent) throw new Error("Keine Antwort von Mistral"); - - let jsonData; try { - jsonData = JSON.parse(rawContent as string); - } catch (e) { - const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, ''); - jsonData = JSON.parse(cleanedText); + const startApi = performance.now(); + const chatResponse = await client.chat.complete({ + model: 'mistral-large-latest', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', imageUrl: dataUrl } + ] + } + ], + responseFormat: { type: 'json_object' }, + temperature: 0.1 + }); + const endApi = performance.now(); + + const startParse = performance.now(); + const rawContent = chatResponse.choices?.[0].message.content; + if (!rawContent) throw new Error("Keine Antwort von Mistral"); + + let jsonData; + try { + jsonData = JSON.parse(rawContent as string); + } catch (e) { + const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, ''); + jsonData = JSON.parse(cleanedText); + } + + if (Array.isArray(jsonData)) jsonData = jsonData[0]; + console.log('[Mistral AI] JSON Response:', jsonData); + + const searchString = jsonData.search_string; + delete jsonData.search_string; + + if (typeof jsonData.abv === 'string') { + jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim()); + } + + 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(); + + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'mistral/mistral-large', + success: true + }); + + await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis'); + + await supabase + .from('vision_cache') + .insert({ hash: imageHash, result: validatedData }); + + return { + success: true, + data: validatedData, + search_string: searchString, + perf: { + apiDuration: endApi - startApi, + parseDuration: endParse - startParse, + uploadSize: uploadSize + }, + raw: jsonData + }; + + } catch (aiError: any) { + console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message); + + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'mistral/mistral-large', + success: false, + errorMessage: aiError.message + }); + + return { + success: false, + isAiError: true, + error: aiError.message, + imageHash: imageHash + } as any; } - 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', - endpoint: 'mistral/mistral-large', - 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 }); - - return { - success: true, - data: validatedData, - search_string: searchString, - perf: { - apiDuration: endApi - startApi, - parseDuration: endParse - startParse, - uploadSize: uploadSize - }, - raw: jsonData - }; - } catch (error) { - console.error('Mistral Analysis Error:', error); - - if (supabase) { - const { data: { session } } = await supabase.auth.getSession(); - if (session?.user) { - await trackApiUsage({ - userId: session.user.id, - apiType: 'gemini_ai', - endpoint: 'mistral/mistral-large', - success: false, - errorMessage: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - + console.error('Mistral Analysis Global Error:', error); return { success: false, error: error instanceof Error ? error.message : 'Mistral AI analysis failed.', diff --git a/src/services/analyze-bottle.ts b/src/services/analyze-bottle.ts index 4116bd1..432efd6 100644 --- a/src/services/analyze-bottle.ts +++ b/src/services/analyze-bottle.ts @@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise { // 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,96 +80,96 @@ export async function analyzeBottle(input: any): Promise { } // 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); - // API Call - const startApi = performance.now(); - const result = await geminiModel.generateContent([ - { - inlineData: { - data: base64Data, - mimeType: mimeType, - }, - }, - { text: instruction }, - ]); - const endApi = performance.now(); - - 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); + // API Call + const startApi = performance.now(); + const result = await geminiModel.generateContent([ + { + inlineData: { + data: base64Data, + mimeType: mimeType, + }, + }, + { text: instruction }, + ]); + const endApi = performance.now(); + + const startParse = performance.now(); + const responseText = result.response.text(); + + let jsonData; + try { + jsonData = JSON.parse(responseText); + } catch (e) { + const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, ''); + jsonData = JSON.parse(cleanedText); + } + + if (Array.isArray(jsonData)) jsonData = jsonData[0]; + console.log('[Gemini AI] JSON Response:', jsonData); + + const searchString = jsonData.search_string; + delete jsonData.search_string; + + if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.'); + + const validatedData = BottleMetadataSchema.parse(jsonData); + const endParse = performance.now(); + + // 6. Tracking & Credits + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'generateContent', + success: true + }); + + await deductCredits(userId, 'gemini_ai', 'Bottle analysis'); + + // Cache speichern + await supabase + .from('vision_cache') + .insert({ hash: imageHash, result: validatedData }); + + return { + success: true, + data: validatedData, + search_string: searchString, + perf: { + apiDuration: endApi - startApi, + parseDuration: endParse - startParse, + uploadSize: uploadSize + }, + raw: jsonData + } as any; + + } catch (aiError: any) { + console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message); + + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'generateContent', + success: false, + errorMessage: aiError.message + }); + + return { + success: false, + isAiError: true, + error: aiError.message, + imageHash: imageHash + } as any; } - if (Array.isArray(jsonData)) jsonData = jsonData[0]; - console.log('[Gemini AI] JSON Response:', jsonData); - - const searchString = jsonData.search_string; - delete jsonData.search_string; - - if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.'); - - const validatedData = BottleMetadataSchema.parse(jsonData); - const endParse = performance.now(); - - // 6. Tracking & Credits (bleibt gleich) - await trackApiUsage({ - userId: userId, - apiType: 'gemini_ai', - endpoint: 'generateContent', - success: true - }); - - await deductCredits(userId, 'gemini_ai', 'Bottle analysis'); - - // Cache speichern - const { error: storeError } = 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, - search_string: searchString, - perf: { - apiDuration: endApi - startApi, - parseDuration: endParse - startParse, - uploadSize: uploadSize - }, - 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) { - await trackApiUsage({ - userId: session.user.id, - apiType: 'gemini_ai', - endpoint: 'generateContent', - success: false, - errorMessage: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - + console.error('Gemini Analysis Global Error:', error); return { success: false, error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.', diff --git a/src/services/buddy.ts b/src/services/buddy.ts index 47f042d..ed34d2b 100644 --- a/src/services/buddy.ts +++ b/src/services/buddy.ts @@ -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 }; diff --git a/src/services/delete-bottle.ts b/src/services/delete-bottle.ts index e18c842..d74a300 100644 --- a/src/services/delete-bottle.ts +++ b/src/services/delete-bottle.ts @@ -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.'); } diff --git a/src/services/delete-session.ts b/src/services/delete-session.ts index 0e8945f..0f94882 100644 --- a/src/services/delete-session.ts +++ b/src/services/delete-session.ts @@ -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; diff --git a/src/services/delete-tasting.ts b/src/services/delete-tasting.ts index e0ad4ab..48fb0f4 100644 --- a/src/services/delete-tasting.ts +++ b/src/services/delete-tasting.ts @@ -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; diff --git a/src/services/find-matching-bottle.ts b/src/services/find-matching-bottle.ts index d70bb54..d64ad5e 100644 --- a/src/services/find-matching-bottle.ts +++ b/src/services/find-matching-bottle.ts @@ -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) { diff --git a/src/services/save-bottle.ts b/src/services/save-bottle.ts index 72165b6..211d1f4 100644 --- a/src/services/save-bottle.ts +++ b/src/services/save-bottle.ts @@ -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, diff --git a/src/services/save-tasting.ts b/src/services/save-tasting.ts index 8caf063..e7f1dea 100644 --- a/src/services/save-tasting.ts +++ b/src/services/save-tasting.ts @@ -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') diff --git a/src/services/tags.ts b/src/services/tags.ts index 70ad8c5..78e4eb4 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -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(); diff --git a/src/services/update-bottle-status.ts b/src/services/update-bottle-status.ts index a9799e0..dd085cb 100644 --- a/src/services/update-bottle-status.ts +++ b/src/services/update-bottle-status.ts @@ -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; diff --git a/src/services/update-bottle.ts b/src/services/update-bottle.ts index 5c4b52c..69fc382 100644 --- a/src/services/update-bottle.ts +++ b/src/services/update-bottle.ts @@ -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; diff --git a/src/types/whisky.ts b/src/types/whisky.ts index 060ec15..053d298 100644 --- a/src/types/whisky.ts +++ b/src/types/whisky.ts @@ -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; 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; @@ -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; } diff --git a/src/utils/generate-dummy-metadata.ts b/src/utils/generate-dummy-metadata.ts new file mode 100644 index 0000000..4137ea5 --- /dev/null +++ b/src/utils/generate-dummy-metadata.ts @@ -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, + }; +} diff --git a/src/utils/image-processing.ts b/src/utils/image-processing.ts index e10c3b4..14b31a2 100644 --- a/src/utils/image-processing.ts +++ b/src/utils/image-processing.ts @@ -47,7 +47,8 @@ export async function processImageForAI(file: File): Promise { maxSizeMB: 0.4, maxWidthOrHeight: 1024, useWebWorker: true, - fileType: 'image/webp' + fileType: 'image/webp', + libURL: '/lib/browser-image-compression.js' }; try {