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:
@@ -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,14 +151,30 @@ export function DrinkImageUpload({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ImagePlus className="h-8 w-8 text-muted-foreground" />
|
<ImagePlus className="h-8 w-8 text-muted-foreground" />
|
||||||
<Button
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="gap-2"
|
||||||
>
|
onClick={() => {
|
||||||
Upload Photo
|
// Without getUserMedia, fall back to the file input's capture
|
||||||
</Button>
|
// 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">
|
<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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user