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>
This commit is contained in:
JP
2026-08-08 19:16:20 +00:00
parent 058735b1a0
commit 593f68138c
2 changed files with 67 additions and 11 deletions

View File

@@ -1,8 +1,9 @@
"use client" "use client"
import { useRef, useState, useCallback } from "react" import { useRef, useState, useCallback, useEffect } from "react"
import { ImagePlus, X, Loader2 } from "lucide-react" import { Camera, ImagePlus, X, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CameraCapture } from "@/components/scan/camera-capture"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface DrinkImageUploadProps { interface DrinkImageUploadProps {
@@ -17,7 +18,19 @@ export function DrinkImageUpload({
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showCamera, setShowCamera] = useState(false)
const [hasGetUserMedia, setHasGetUserMedia] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) 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( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
@@ -76,6 +89,19 @@ export function DrinkImageUpload({
onImageChange(null) onImageChange(null)
setError(null) setError(null)
if (fileInputRef.current) fileInputRef.current.value = "" 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) { if (imageUrl) {
@@ -125,6 +151,21 @@ export function DrinkImageUpload({
) : ( ) : (
<> <>
<ImagePlus className="h-8 w-8 text-muted-foreground" /> <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 <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -133,6 +174,7 @@ export function DrinkImageUpload({
> >
Upload Photo Upload Photo
</Button> </Button>
</div>
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
or drag and drop an image here or drag and drop an image here
</p> </p>
@@ -143,6 +185,17 @@ export function DrinkImageUpload({
{error && <p className="text-sm text-destructive mt-1.5">{error}</p>} {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 <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View File

@@ -82,7 +82,7 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
<div className="flex flex-col items-center gap-4 py-12 text-center"> <div className="flex flex-col items-center gap-4 py-12 text-center">
<Camera className="h-12 w-12 text-muted-foreground" /> <Camera className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Close Close
</Button> </Button>
</div> </div>
@@ -103,6 +103,7 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
{/* Overlay controls */} {/* Overlay controls */}
<div className="absolute bottom-0 inset-x-0 flex items-center justify-center gap-4 p-4 bg-gradient-to-t from-black/60 to-transparent"> <div className="absolute bottom-0 inset-x-0 flex items-center justify-center gap-4 p-4 bg-gradient-to-t from-black/60 to-transparent">
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-white hover:bg-white/20" className="text-white hover:bg-white/20"
@@ -111,6 +112,7 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
<Button <Button
type="button"
size="lg" size="lg"
className="rounded-full h-16 w-16 bg-white hover:bg-white/90" className="rounded-full h-16 w-16 bg-white hover:bg-white/90"
onClick={handleCapture} onClick={handleCapture}
@@ -118,6 +120,7 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
<Camera className="h-6 w-6 text-black" /> <Camera className="h-6 w-6 text-black" />
</Button> </Button>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-white hover:bg-white/20" className="text-white hover:bg-white/20"