Add recipes, images, AI photo ID, barcode scanning & ingredient matching

- 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 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-04 22:26:17 -07:00
parent 2ac2c4b2d4
commit dc1ad4d0c0
36 changed files with 1892 additions and 144 deletions

View File

@@ -20,7 +20,7 @@ export function BottomNav() {
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card border-t safe-area-bottom">
<div className="flex items-center justify-around h-16">
{primaryItems.map((item) => {
const isActive = pathname.startsWith(item.href)
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
return (
<Link
key={item.href}

View File

@@ -0,0 +1,252 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useQuery } from "@tanstack/react-query"
import {
ChevronDown,
ChevronRight,
Clock,
Search,
Camera,
Wine,
Star,
} from "lucide-react"
import { cn } from "@/lib/utils"
interface RecentSearch {
id: string
query: string
resultCount: number
createdAt: string
}
interface RecentScan {
id: string
status: string
itemCount: number
createdAt: string
}
interface RecentDrink {
id: string
name: string
type: string
avgRating: number | null
createdAt: string
}
interface HistoryData {
searches: RecentSearch[]
scans: RecentScan[]
drinks: RecentDrink[]
}
function useRecentHistory() {
return useQuery<HistoryData>({
queryKey: ["recent-history"],
queryFn: async () => {
const [searchRes, scanRes, drinkRes] = await Promise.all([
fetch("/api/ai/search/history").then((r) =>
r.ok ? r.json() : { searches: [] }
),
fetch("/api/scan?limit=5&page=1").then((r) =>
r.ok ? r.json() : { scans: [] }
),
fetch("/api/drinks?sort=recent&limit=5").then((r) =>
r.ok ? r.json() : { drinks: [] }
),
])
return {
searches: (searchRes.searches || []).slice(0, 5).map(
(s: { id: string; query: string; results?: { drinks?: unknown[] }; createdAt: string }) => ({
id: s.id,
query: s.query,
resultCount: s.results?.drinks?.length || 0,
createdAt: s.createdAt,
})
),
scans: (scanRes.scans || []).slice(0, 5).map(
(s: { id: string; status: string; items?: unknown[]; createdAt: string }) => ({
id: s.id,
status: s.status,
itemCount: s.items?.length || 0,
createdAt: s.createdAt,
})
),
drinks: (drinkRes.drinks || []).slice(0, 5).map(
(d: { id: string; name: string; type: string; avgRating: number | null; createdAt: string }) => ({
id: d.id,
name: d.name,
type: d.type,
avgRating: d.avgRating,
createdAt: d.createdAt,
})
),
}
},
staleTime: 30 * 1000, // 30s
refetchOnWindowFocus: false,
})
}
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
const mins = Math.floor(diff / 60000)
if (mins < 1) return "just now"
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
const weeks = Math.floor(days / 7)
if (weeks < 5) return `${weeks}w ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}
function CollapsibleSection({
title,
icon: Icon,
children,
count,
defaultOpen = false,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
children: React.ReactNode
count: number
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
if (count === 0) return null
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{open ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<Icon className="h-3.5 w-3.5" />
<span>{title}</span>
<span className="ml-auto text-[10px] opacity-60">{count}</span>
</button>
{open && <div className="space-y-0.5 mt-0.5">{children}</div>}
</div>
)
}
export function HistorySection() {
const { data, isLoading } = useRecentHistory()
if (isLoading || !data) return null
const hasAny =
data.searches.length > 0 ||
data.scans.length > 0 ||
data.drinks.length > 0
if (!hasAny) return null
return (
<div className="border-t px-2 py-3 space-y-1">
<div className="flex items-center gap-1.5 px-3 pb-1">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Recent
</span>
</div>
<CollapsibleSection
title="Searches"
icon={Search}
count={data.searches.length}
defaultOpen
>
{data.searches.map((s) => (
<Link
key={s.id}
href="/drinks"
className="flex items-center gap-2 px-3 py-1 mx-1 rounded text-xs hover:bg-accent transition-colors group"
>
<span className="truncate flex-1 text-muted-foreground group-hover:text-foreground">
{s.query}
</span>
<span className="text-[10px] text-muted-foreground/60 shrink-0">
{timeAgo(s.createdAt)}
</span>
</Link>
))}
</CollapsibleSection>
<CollapsibleSection
title="Scans"
icon={Camera}
count={data.scans.length}
>
{data.scans.map((s) => (
<Link
key={s.id}
href={`/scan/${s.id}`}
className="flex items-center gap-2 px-3 py-1 mx-1 rounded text-xs hover:bg-accent transition-colors group"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full shrink-0",
s.status === "COMPLETED"
? "bg-green-500"
: s.status === "FAILED"
? "bg-red-500"
: "bg-yellow-500"
)}
/>
<span className="truncate flex-1 text-muted-foreground group-hover:text-foreground">
{s.itemCount} items
</span>
<span className="text-[10px] text-muted-foreground/60 shrink-0">
{timeAgo(s.createdAt)}
</span>
</Link>
))}
</CollapsibleSection>
<CollapsibleSection
title="Drinks"
icon={Wine}
count={data.drinks.length}
>
{data.drinks.map((d) => (
<Link
key={d.id}
href={`/drinks/${d.id}`}
className="flex items-center gap-2 px-3 py-1 mx-1 rounded text-xs hover:bg-accent transition-colors group"
>
<span className="truncate flex-1 text-muted-foreground group-hover:text-foreground">
{d.name}
</span>
{d.avgRating !== null && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60 shrink-0">
<Star className="h-2.5 w-2.5 fill-primary text-primary" />
{d.avgRating.toFixed(1)}
</span>
)}
</Link>
))}
</CollapsibleSection>
</div>
)
}

View File

@@ -10,11 +10,13 @@ import {
Settings,
MoreHorizontal,
X,
BookOpen,
} from "lucide-react"
import { cn } from "@/lib/utils"
const moreItems = [
{ href: "/scan", label: "Scan Menu", icon: Camera },
{ href: "/recipes", label: "Recipes", icon: BookOpen },
{ href: "/recommend", label: "For You", icon: Sparkles },
{ href: "/wishlist", label: "Try Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
@@ -25,7 +27,7 @@ export function MoreMenu() {
const pathname = usePathname()
const isActiveInMore = moreItems.some((item) =>
pathname.startsWith(item.href)
pathname === item.href || pathname.startsWith(item.href + "/")
)
return (
@@ -56,7 +58,7 @@ export function MoreMenu() {
{/* Menu */}
<div className="absolute bottom-full right-0 mb-2 z-50 bg-card border rounded-lg shadow-lg p-2 min-w-[160px]">
{moreItems.map((item) => {
const isActive = pathname.startsWith(item.href)
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
return (
<Link
key={item.href}

View File

@@ -14,9 +14,11 @@ import {
FlaskConical,
GlassWater,
Sparkles,
BookOpen,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HistorySection } from "./history-section"
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@@ -24,6 +26,7 @@ const navItems = [
{ href: "/drinks", label: "My Drinks", icon: Wine },
{ href: "/bar", label: "My Bar", icon: FlaskConical },
{ href: "/bartender", label: "Bartender", icon: GlassWater },
{ href: "/recipes", label: "Recipes", icon: BookOpen },
{ href: "/recommend", label: "Recommend", icon: Sparkles },
{ href: "/wishlist", label: "Try Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
@@ -43,7 +46,7 @@ export function Sidebar() {
<nav className="flex-1 px-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href)
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
return (
<Link
key={item.href}
@@ -62,6 +65,8 @@ export function Sidebar() {
})}
</nav>
<HistorySection />
<div className="p-4 border-t">
{session?.user && (
<div className="flex items-center gap-3 mb-3">