From dc1ad4d0c03587f4f4923a985f855ecdd4a538a8 Mon Sep 17 00:00:00 2001 From: JP Scott Date: Wed, 4 Mar 2026 22:26:17 -0700 Subject: [PATCH] Add recipes, images, AI photo ID, barcode scanning & ingredient matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fuzzy ingredient matching for bar inventory against recipes - AI photo identification API for bottles/labels (drink + bar context) - Barcode scanner with photo toggle for My Bar - Barcode scan + photo ID buttons on Add Drink form - Auto-pull product images from Open Food Facts barcode lookup - Recipes section on drink detail pages with bar availability - Dedicated Recipes page in sidebar navigation - Bar item image support (schema, upload, display) - Drink detail image upload component - MinIO image proxy through Next.js rewrites (fixes broken image links) - Improved category mapping (energy drinks → Mixers, not Spirits) - Re-process saved recipe ingredients against current bar inventory Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + docker-compose.yml | 1 + next.config.mjs | 12 + package-lock.json | 7 + package.json | 3 +- prisma/schema.prisma | 3 + src/app/(app)/bar/page.tsx | 64 ++- src/app/(app)/bartender/page.tsx | 5 +- src/app/(app)/drinks/[id]/page.tsx | 79 +++- src/app/(app)/recipes/page.tsx | 21 + src/app/api/ai/identify/route.ts | 116 +++++ src/app/api/bar/barcode-lookup/route.ts | 177 ++++++++ src/app/api/bartender/recreate/route.ts | 7 + src/app/api/bartender/suggest/route.ts | 16 + src/app/api/recipes/route.ts | 40 +- src/components/bar/bar-item-card.tsx | 9 + src/components/bar/bar-item-form.tsx | 22 +- src/components/bar/barcode-scan-dialog.tsx | 310 ++++++++++++++ src/components/bar/barcode-scanner.tsx | 150 +++++++ src/components/bartender/recreate-tab.tsx | 43 +- src/components/drinks/drink-card.tsx | 22 +- src/components/drinks/drink-detail-image.tsx | 45 ++ src/components/drinks/drink-form.tsx | 420 +++++++++++++++---- src/components/drinks/drink-recipes-list.tsx | 22 + src/components/layout/bottom-nav.tsx | 2 +- src/components/layout/history-section.tsx | 252 +++++++++++ src/components/layout/more-menu.tsx | 6 +- src/components/layout/sidebar.tsx | 7 +- src/hooks/use-bar.ts | 2 + src/hooks/use-barcode-lookup.ts | 33 ++ src/hooks/use-identify.ts | 39 ++ src/lib/ai/prompts.ts | 18 +- src/lib/ingredient-matcher.ts | 68 +++ src/lib/s3.ts | 10 +- src/lib/validators.ts | 2 + src/middleware.ts | 1 + 36 files changed, 1892 insertions(+), 144 deletions(-) create mode 100644 src/app/(app)/recipes/page.tsx create mode 100644 src/app/api/ai/identify/route.ts create mode 100644 src/app/api/bar/barcode-lookup/route.ts create mode 100644 src/components/bar/barcode-scan-dialog.tsx create mode 100644 src/components/bar/barcode-scanner.tsx create mode 100644 src/components/drinks/drink-detail-image.tsx create mode 100644 src/components/drinks/drink-recipes-list.tsx create mode 100644 src/components/layout/history-section.tsx create mode 100644 src/hooks/use-barcode-lookup.ts create mode 100644 src/hooks/use-identify.ts create mode 100644 src/lib/ingredient-matcher.ts diff --git a/.gitignore b/.gitignore index 3b02042..d1caaf8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 09d48c0..6662ded 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,7 @@ services: environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}" MINIO_ENDPOINT: "minio" + WATCHPACK_POLLING: "true" depends_on: db: condition: service_healthy diff --git a/next.config.mjs b/next.config.mjs index 842fe14..1c807cb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,18 @@ const nextConfig = { }, ], }, + async rewrites() { + // Proxy image requests to MinIO so URLs work from any device + const minioHost = process.env.MINIO_ENDPOINT || "localhost"; + const minioPort = process.env.MINIO_PORT || "9000"; + const minioBucket = process.env.MINIO_BUCKET || "drink-images"; + return [ + { + source: "/minio-images/:path*", + destination: `http://${minioHost}:${minioPort}/${minioBucket}/:path*`, + }, + ]; + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index b62ceed..822c276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.575.0", "next": "14.2.35", "next-auth": "^5.0.0-beta.25", @@ -5359,6 +5360,12 @@ "node": ">= 0.4" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 188dddc..be4a808 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "next lint" @@ -18,6 +18,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.575.0", "next": "14.2.35", "next-auth": "^5.0.0-beta.25", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de3c8b3..51f11e5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,8 @@ model BarItem { category BarItemCategory quantity BarItemQuantity @default(FULL) notes String? @db.Text + barcode String? + imageUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -286,6 +288,7 @@ model BarItem { @@index([userId]) @@index([userId, category]) + @@index([userId, barcode]) } // ─── Recipes ──────────────────────────────────────────────────── diff --git a/src/app/(app)/bar/page.tsx b/src/app/(app)/bar/page.tsx index 41fa168..de9b452 100644 --- a/src/app/(app)/bar/page.tsx +++ b/src/app/(app)/bar/page.tsx @@ -20,7 +20,9 @@ import { useDeleteBarItem, } from "@/hooks/use-bar" import type { BarItem } from "@/hooks/use-bar" -import { Plus, Wine } from "lucide-react" +import { BarcodeScanDialog } from "@/components/bar/barcode-scan-dialog" +import type { BarcodeLookupResult } from "@/hooks/use-barcode-lookup" +import { Plus, Wine, ScanLine } from "lucide-react" import type { BarItemCreate } from "@/lib/validators" export default function BarPage() { @@ -65,17 +67,40 @@ const CATEGORY_ORDER = [ function BarContent() { const [addDialogOpen, setAddDialogOpen] = useState(false) + const [scanDialogOpen, setScanDialogOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) + const [scannedData, setScannedData] = useState | null>(null) const { data, isLoading, error } = useBarItems() const createBarItem = useCreateBarItem() const updateBarItem = useUpdateBarItem() const deleteBarItem = useDeleteBarItem() + function handleScanResult(result: BarcodeLookupResult) { + const initial: Partial & { imageUrl?: string } = { + barcode: result.barcode, + } + if (result.name) { + initial.name = result.brand + ? `${result.brand} ${result.name}` + : result.name + } + if (result.category) { + initial.category = result.category as BarItemCreate["category"] + } + if (result.imageUrl) { + initial.imageUrl = result.imageUrl + } + setScannedData(initial) + // Scan dialog closes itself before calling this — just open add form + setAddDialogOpen(true) + } + function handleCreate(formData: BarItemCreate) { createBarItem.mutate(formData, { onSuccess: () => { setAddDialogOpen(false) + setScannedData(null) }, }) } @@ -124,10 +149,16 @@ function BarContent() { : "Your bar inventory"}
No saved recipes for this drink.
+ Your collection of saved cocktail recipes. +
{errors.name}
+ {scanMode === "barcode" ? "Looking up product..." : "AI is identifying the product..."} +
+ Barcode: {scannedBarcode} +
Product found!
+ {foundResult.brand && foundResult.name + ? `${foundResult.brand} ${foundResult.name}` + : foundResult.name || "Unknown product"} +
+ Category: {foundResult.category} +
+ UPC: {scannedBarcode} +
Already in your bar!
+ “{existingName}” is already in your inventory. +
+ {scanMode === "barcode" ? "Lookup failed" : "Identification failed"} +
+ {errorMessage || "Something went wrong. Please try again."} +
Starting camera...
+ Point your camera at a barcode on a bottle or can +
{error}
+ {scanType === "barcode" ? "Looking up product..." : "AI is identifying..."} +
{scanResult.name}
+ Type: {scanResult.type} + {scanResult.subType ? ` / ${scanResult.subType}` : ""} +
+ {scanResult.brewery} +
Identification failed
+ {scanError || "Something went wrong. Please try again."} +