Add My Bar, Bartender, Recommend features + drink images

- Drink Images: upload/display photos of bottles/cans on drink cards and detail pages
- My Bar: inventory tracker for spirits, liqueurs, mixers, bitters, garnishes, tools
- Bartender: AI-powered cocktail recipe generation, "what can I make" suggestions,
  saved recipes. Cross-references bar inventory for ingredient availability.
- Recommend: AI flavor profile analysis, personalized drink recommendations,
  "find similar" drinks based on highly-rated favorites
- Navigation: desktop sidebar with all 8 routes, mobile bottom nav with
  4 primary items + "More" popup menu
- New Prisma models: BarItem, Recipe, FlavorProfile
- Backup/restore updated to include bar items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 18:28:02 -07:00
parent d8f069cce4
commit 2ac2c4b2d4
40 changed files with 3709 additions and 11 deletions

View File

@@ -0,0 +1,195 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Check,
X,
ChevronDown,
ChevronUp,
Bookmark,
Trash2,
GlassWater,
Loader2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { RecipeIngredient } from "@/hooks/use-bartender"
export interface RecipeCardData {
id?: string
title: string
ingredients: RecipeIngredient[]
steps: string[]
garnish?: string | null
glassware?: string | null
notes?: string | null
missingCount?: number
sourceDrink?: { name: string; type: string } | null
}
interface RecipeCardProps {
recipe: RecipeCardData
onSave?: (recipe: RecipeCardData) => void
onDelete?: (id: string) => void
isSaving?: boolean
isDeleting?: boolean
saved?: boolean
}
export function RecipeCard({
recipe,
onSave,
onDelete,
isSaving,
isDeleting,
saved,
}: RecipeCardProps) {
const [expanded, setExpanded] = useState(false)
const availableCount = recipe.ingredients.filter((i) => i.available).length
const totalCount = recipe.ingredients.length
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg leading-tight">
{recipe.title}
</CardTitle>
<div className="flex items-center gap-1 shrink-0">
{recipe.missingCount !== undefined && (
<Badge
variant={recipe.missingCount === 0 ? "default" : "secondary"}
className={cn(
"text-xs",
recipe.missingCount === 0 &&
"bg-green-500/15 text-green-700 border-green-500/25"
)}
>
{recipe.missingCount === 0
? "Ready"
: `Missing ${recipe.missingCount}`}
</Badge>
)}
{!saved && onSave && (
<Button
variant="ghost"
size="sm"
onClick={() => onSave(recipe)}
disabled={isSaving}
className="h-8 w-8 p-0"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Bookmark className="h-4 w-4" />
)}
</Button>
)}
{saved && recipe.id && onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(recipe.id!)}
disabled={isDeleting}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
{recipe.glassware && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<GlassWater className="h-3.5 w-3.5" />
{recipe.glassware}
</div>
)}
</CardHeader>
<CardContent className="space-y-3">
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
Ingredients
</h4>
<span className="text-xs text-muted-foreground">
{availableCount}/{totalCount} available
</span>
</div>
<ul className="space-y-1">
{recipe.ingredients.map((ing, i) => (
<li
key={i}
className={cn(
"flex items-center gap-2 text-sm",
!ing.available && "text-muted-foreground"
)}
>
{ing.available ? (
<Check className="h-3.5 w-3.5 text-green-600 shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-destructive shrink-0" />
)}
<span>
{ing.amount} {ing.name}
</span>
</li>
))}
</ul>
</div>
{/* Garnish */}
{recipe.garnish && (
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Garnish:</span>{" "}
{recipe.garnish}
</p>
)}
{/* Expandable Steps */}
<div>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{expanded ? "Hide steps" : "Show steps"}
</button>
{expanded && (
<ol className="mt-2 space-y-2">
{recipe.steps.map((step, i) => (
<li key={i} className="flex gap-2 text-sm">
<span className="shrink-0 flex items-center justify-center h-5 w-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
{i + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
)}
</div>
{/* Notes */}
{expanded && recipe.notes && (
<div className="rounded-md bg-muted/50 p-3">
<p className="text-sm text-muted-foreground">{recipe.notes}</p>
</div>
)}
</CardContent>
</Card>
)
}