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,158 @@
"use client"
import { useRef, useState, useCallback } from "react"
import { ImagePlus, X, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface DrinkImageUploadProps {
imageUrl?: string | null
onImageChange: (url: string | null) => void
}
export function DrinkImageUpload({
imageUrl,
onImageChange,
}: DrinkImageUploadProps) {
const [isUploading, setIsUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const uploadFile = useCallback(
async (file: File) => {
setError(null)
setIsUploading(true)
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || "Failed to upload image")
}
const { url } = await res.json()
onImageChange(url)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to upload image")
} finally {
setIsUploading(false)
}
},
[onImageChange]
)
const handleFile = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) {
setError("Please select an image file")
return
}
uploadFile(file)
},
[uploadFile]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragActive(false)
const file = e.dataTransfer.files[0]
if (file) {
handleFile(file)
}
},
[handleFile]
)
const handleRemove = () => {
onImageChange(null)
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
if (imageUrl) {
return (
<div className="relative">
<img
src={imageUrl}
alt="Drink photo"
className="w-full rounded-lg max-h-[300px] object-contain bg-muted"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={handleRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
)
}
return (
<div>
<div
className={cn(
"border-2 border-dashed rounded-lg transition-colors",
dragActive
? "border-primary bg-primary/5"
: "border-muted-foreground/25",
isUploading && "pointer-events-none opacity-60"
)}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
<div className="flex flex-col items-center gap-3 py-8">
{isUploading ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Uploading...</p>
</>
) : (
<>
<ImagePlus className="h-8 w-8 text-muted-foreground" />
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Upload Photo
</Button>
<p className="text-xs text-muted-foreground text-center">
or drag and drop an image here
</p>
</>
)}
</div>
</div>
{error && <p className="text-sm text-destructive mt-1.5">{error}</p>}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</div>
)
}