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:
@@ -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}
|
||||
|
||||
252
src/components/layout/history-section.tsx
Normal file
252
src/components/layout/history-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user