The bar add-item form could only pick an existing file, so adding a bottle meant taking a photo first and then hunting for it. The scan flow already had a working camera; this reuses that CameraCapture component rather than adding a second implementation. The change is in DrinkImageUpload, which the bar form, the drink form and the drink detail view all share, so all three gain the camera. Falls back to a file input with capture="environment" when getUserMedia is unavailable - it needs a secure context, so it is absent when the app is reached over plain http on the LAN. Also sets type="button" on CameraCapture's controls. They previously had no type, which defaults to submit, so capturing a photo inside the bar item form would have submitted the form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
5.8 KiB
TypeScript
212 lines
5.8 KiB
TypeScript
"use client"
|
|
|
|
import { useRef, useState, useCallback, useEffect } from "react"
|
|
import { Camera, ImagePlus, X, Loader2 } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { CameraCapture } from "@/components/scan/camera-capture"
|
|
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 [showCamera, setShowCamera] = useState(false)
|
|
const [hasGetUserMedia, setHasGetUserMedia] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// getUserMedia needs a secure context, so it is absent when the app is reached
|
|
// over plain http on the LAN. Checked after mount because it is not available
|
|
// during server rendering.
|
|
useEffect(() => {
|
|
setHasGetUserMedia(
|
|
typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
|
|
)
|
|
}, [])
|
|
|
|
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 (cameraInputRef.current) cameraInputRef.current.value = ""
|
|
}
|
|
|
|
if (showCamera) {
|
|
return (
|
|
<CameraCapture
|
|
onCapture={(file) => {
|
|
setShowCamera(false)
|
|
handleFile(file)
|
|
}}
|
|
onClose={() => setShowCamera(false)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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" />
|
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="gap-2"
|
|
onClick={() => {
|
|
// Without getUserMedia, fall back to the file input's capture
|
|
// hint, which opens the camera app on mobile.
|
|
if (hasGetUserMedia) setShowCamera(true)
|
|
else cameraInputRef.current?.click()
|
|
}}
|
|
>
|
|
<Camera className="h-4 w-4" />
|
|
Take Photo
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
Upload Photo
|
|
</Button>
|
|
</div>
|
|
<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={cameraInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/heic"
|
|
capture="environment"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleFile(file)
|
|
}}
|
|
/>
|
|
<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>
|
|
)
|
|
}
|