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:
158
src/components/drinks/drink-image-upload.tsx
Normal file
158
src/components/drinks/drink-image-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user