Files
drinktracker/src/components/drinks/drink-image-upload.tsx
JP 593f68138c Add a camera option wherever a photo can be attached
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>
2026-08-08 19:16:20 +00:00

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>
)
}