Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState, useRef, useEffect } from 'react'; | |
| import { X, Camera, RotateCcw, Check } from 'lucide-react'; | |
| import clsx from 'clsx'; | |
| interface CameraModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onCapture: (file: File) => void; | |
| } | |
| export default function CameraModal({ isOpen, onClose, onCapture }: CameraModalProps) { | |
| const videoRef = useRef<HTMLVideoElement>(null); | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const [stream, setStream] = useState<MediaStream | null>(null); | |
| const [capturedImage, setCapturedImage] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment'); | |
| useEffect(() => { | |
| if (isOpen) { | |
| startCamera(); | |
| } else { | |
| stopCamera(); | |
| setCapturedImage(null); | |
| } | |
| return () => stopCamera(); | |
| }, [isOpen, facingMode]); | |
| const startCamera = async () => { | |
| try { | |
| setError(null); | |
| const mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| facingMode: facingMode, | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 } | |
| }, | |
| audio: false | |
| }); | |
| setStream(mediaStream); | |
| if (videoRef.current) { | |
| videoRef.current.srcObject = mediaStream; | |
| } | |
| } catch (err) { | |
| console.error('Camera error:', err); | |
| setError('Unable to access camera. Please check permissions.'); | |
| } | |
| }; | |
| const stopCamera = () => { | |
| if (stream) { | |
| stream.getTracks().forEach(track => track.stop()); | |
| setStream(null); | |
| } | |
| }; | |
| const switchCamera = () => { | |
| setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); | |
| }; | |
| const capturePhoto = () => { | |
| if (videoRef.current && canvasRef.current) { | |
| const video = videoRef.current; | |
| const canvas = canvasRef.current; | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| if (ctx) { | |
| ctx.drawImage(video, 0, 0); | |
| const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9); | |
| setCapturedImage(imageDataUrl); | |
| stopCamera(); | |
| } | |
| } | |
| }; | |
| const retake = () => { | |
| setCapturedImage(null); | |
| startCamera(); | |
| }; | |
| const confirmCapture = () => { | |
| if (capturedImage && canvasRef.current) { | |
| canvasRef.current.toBlob((blob) => { | |
| if (blob) { | |
| const file = new File([blob], `photo_${Date.now()}.jpg`, { type: 'image/jpeg' }); | |
| onCapture(file); | |
| onClose(); | |
| } | |
| }, 'image/jpeg', 0.9); | |
| } | |
| }; | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90 backdrop-blur-sm animate-fade-in-up"> | |
| <div className="w-full max-w-xl bg-neutral-900 rounded-2xl shadow-2xl border border-neutral-800 overflow-hidden"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-neutral-800"> | |
| <div className="flex items-center gap-2"> | |
| <Camera size={20} className="text-sky-500" /> | |
| <h2 className="text-lg font-semibold text-white">Take Photo</h2> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="p-2 hover:bg-neutral-800 rounded-full" | |
| > | |
| <X size={20} className="text-gray-400" /> | |
| </button> | |
| </div> | |
| {/* Camera View */} | |
| <div className="relative aspect-[4/3] bg-black"> | |
| {error ? ( | |
| <div className="absolute inset-0 flex items-center justify-center text-center p-6"> | |
| <div> | |
| <Camera size={48} className="mx-auto mb-4 text-gray-600" /> | |
| <p className="text-gray-400">{error}</p> | |
| </div> | |
| </div> | |
| ) : capturedImage ? ( | |
| <img | |
| src={capturedImage} | |
| alt="Captured" | |
| className="w-full h-full object-contain" | |
| /> | |
| ) : ( | |
| <video | |
| ref={videoRef} | |
| autoPlay | |
| playsInline | |
| muted | |
| className="w-full h-full object-cover" | |
| /> | |
| )} | |
| <canvas ref={canvasRef} className="hidden" /> | |
| </div> | |
| {/* Controls */} | |
| <div className="p-4 flex items-center justify-center gap-4"> | |
| {!capturedImage ? ( | |
| <> | |
| {/* Switch Camera */} | |
| <button | |
| onClick={switchCamera} | |
| className="p-3 bg-neutral-800 hover:bg-neutral-700 rounded-full transition-colors" | |
| title="Switch Camera" | |
| > | |
| <RotateCcw size={20} className="text-white" /> | |
| </button> | |
| {/* Capture Button */} | |
| <button | |
| onClick={capturePhoto} | |
| disabled={!stream} | |
| className={clsx( | |
| "w-16 h-16 rounded-full border-4 border-white flex items-center justify-center transition-all", | |
| stream | |
| ? "bg-white hover:bg-gray-200 active:scale-95" | |
| : "bg-gray-600 border-gray-600 cursor-not-allowed" | |
| )} | |
| > | |
| <div className="w-12 h-12 rounded-full bg-sky-500"></div> | |
| </button> | |
| {/* Placeholder for symmetry */} | |
| <div className="w-11"></div> | |
| </> | |
| ) : ( | |
| <> | |
| {/* Retake */} | |
| <button | |
| onClick={retake} | |
| className="flex-1 py-3 bg-neutral-800 text-white rounded-xl font-medium hover:bg-neutral-700 flex items-center justify-center gap-2" | |
| > | |
| <RotateCcw size={18} /> | |
| Retake | |
| </button> | |
| {/* Confirm */} | |
| <button | |
| onClick={confirmCapture} | |
| className="flex-1 py-3 bg-sky-500 text-white rounded-xl font-medium hover:bg-sky-600 flex items-center justify-center gap-2" | |
| > | |
| <Check size={18} /> | |
| Use Photo | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |