feat(phase2): implement ItemPhotoUpload component and hook
This commit is contained in:
144
frontend/components/ItemPhotoUpload.tsx
Normal file
144
frontend/components/ItemPhotoUpload.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera, Upload, Loader2 } from 'lucide-react';
|
||||
import { usePhotoUpload } from '@/hooks/usePhotoUpload';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemPhotoUploadProps {
|
||||
itemId: number;
|
||||
onUploadSuccess: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
||||
onError: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
export default function ItemPhotoUpload({
|
||||
itemId,
|
||||
onUploadSuccess,
|
||||
onError,
|
||||
}: ItemPhotoUploadProps) {
|
||||
const { upload, isLoading, error } = usePhotoUpload();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Update local error when hook error changes
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
setLocalError(error);
|
||||
onError(error);
|
||||
}
|
||||
}, [error, onError]);
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
setLocalError(null);
|
||||
|
||||
if (!file) return;
|
||||
|
||||
const toastId = toast.loading('Uploading...');
|
||||
|
||||
try {
|
||||
const photo = await upload(file, itemId);
|
||||
toast.success('Photo uploaded successfully', { id: toastId });
|
||||
onUploadSuccess(photo);
|
||||
|
||||
// Reset file inputs
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
if (cameraInputRef.current) {
|
||||
cameraInputRef.current.value = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || 'Upload failed';
|
||||
setLocalError(errorMsg);
|
||||
toast.error(errorMsg, { id: toastId });
|
||||
onError(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const triggerCameraInput = () => {
|
||||
cameraInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
className="sr-only"
|
||||
aria-label="Upload photo from device"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="sr-only"
|
||||
aria-label="Capture photo with camera"
|
||||
/>
|
||||
|
||||
{/* Button group */}
|
||||
<div className="flex gap-2">
|
||||
{/* File upload button */}
|
||||
<button
|
||||
onClick={triggerFileInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-primary text-white rounded-lg font-normal text-base transition-colors hover:bg-primary/90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Upload photo"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
|
||||
{/* Camera button (mobile) */}
|
||||
<button
|
||||
onClick={triggerCameraInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-slate-700 text-white rounded-lg font-normal text-base transition-colors hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Capture photo with camera"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-5 h-5" />
|
||||
)}
|
||||
<span>Camera</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-slate-400">Uploading...</div>
|
||||
)}
|
||||
|
||||
{localError && (
|
||||
<div className="text-sm text-rose-500">{localError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user