TwoTh / frontend /src /components /UploadZone.tsx
Aravindselvan
Migrate TwoTh to Hugging Face Space pipeline
d325ede
Raw
History Blame Contribute Delete
8.7 kB
/**
* UploadZone — Drag-and-drop zone with click-to-browse, camera capture,
* and full mobile responsiveness.
*
* On mobile/tablet the component shows three distinct action buttons:
* 1. Browse Files — standard file-picker (all images in gallery)
* 2. Take Photo — opens camera directly via `capture="environment"`
* 3. (desktop) — full drag-and-drop surface as usual
*
* Expected input: 4 required orthographic views (Front, Back, Left, Right)
* plus up to 2 optional views (Top, Bottom).
*/
import { useCallback, useRef, useState } from 'react';
import { motion } from 'framer-motion';
interface UploadZoneProps {
onFilesSelected: (files: File[]) => void;
disabled?: boolean;
fileCount: number;
}
const MAX_FILES = 6;
const ACCEPTED = 'image/jpeg,image/png';
/** Detect a coarse-grained touch/mobile environment */
function isMobileDevice(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
}
export default function UploadZone({ onFilesSelected, disabled, fileCount }: UploadZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const browseRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const isFull = fileCount >= MAX_FILES;
const isBlocked = disabled || isFull;
/* ── file helpers ── */
const filterAndEmit = useCallback(
(rawFiles: FileList | null) => {
if (!rawFiles || isBlocked) return;
const valid = Array.from(rawFiles).filter(
(f) => f.type === 'image/jpeg' || f.type === 'image/png',
);
if (valid.length > 0) onFilesSelected(valid);
},
[isBlocked, onFilesSelected],
);
/* ── drag-and-drop handlers ── */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
filterAndEmit(e.dataTransfer.files);
},
[filterAndEmit],
);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!isBlocked) setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
/* ── click handlers ── */
const openBrowse = () => {
if (!isBlocked) browseRef.current?.click();
};
const openCamera = () => {
if (!isBlocked) cameraRef.current?.click();
};
const onBrowseChange = (e: React.ChangeEvent<HTMLInputElement>) => {
filterAndEmit(e.target.files);
e.target.value = '';
};
const onCameraChange = (e: React.ChangeEvent<HTMLInputElement>) => {
filterAndEmit(e.target.files);
e.target.value = '';
};
/* ── derived UI state ── */
const mobile = isMobileDevice();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative"
>
{/* Hidden file inputs */}
<input
ref={browseRef}
type="file"
multiple
accept={ACCEPTED}
onChange={onBrowseChange}
className="hidden"
aria-hidden="true"
/>
{/* Camera input — capture="environment" opens rear camera on mobile */}
<input
ref={cameraRef}
type="file"
multiple
accept={ACCEPTED}
capture="environment"
onChange={onCameraChange}
className="hidden"
aria-hidden="true"
/>
{/* ── Main drop surface (desktop-first) ── */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`
border-2 border-dashed rounded-2xl p-6 sm:p-10 text-center
transition-all duration-300 ease-out select-none
${isBlocked
? 'border-white/10 opacity-50 cursor-not-allowed'
: isDragging
? 'border-cyan-400 bg-cyan-400/10 shadow-[0_0_40px_rgba(34,211,238,0.25)] scale-[1.015]'
: 'border-white/20 hover:border-indigo-400 hover:bg-indigo-400/5'
}
`}
>
<div className="flex flex-col items-center gap-5">
{/* Icon */}
<div
className={`p-4 rounded-full bg-white/5 transition-all duration-300
${isDragging ? 'animate-pulse scale-110' : ''}`}
>
<svg
className={`w-10 h-10 sm:w-12 sm:h-12 transition-colors duration-300
${isDragging ? 'text-cyan-400' : 'text-white/40'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
{/* Primary label */}
<div>
<p className="text-base sm:text-lg font-semibold text-white/90">
{isFull
? 'Maximum images reached'
: isDragging
? 'Drop images here!'
: mobile
? 'Add photos of your object'
: 'Drag & drop images here'}
</p>
{!isBlocked && (
<p className="text-xs sm:text-sm text-white/50 mt-1">
{fileCount === 0
? 'Need Front, Back, Left, Right views (4 required, up to 6 total)'
: `${fileCount} / ${MAX_FILES} images added — need ${Math.max(0, 4 - fileCount)} more required views`}
</p>
)}
</div>
{/* Format badges */}
<div className="flex gap-2 flex-wrap justify-center">
{['JPG', 'PNG', 'Max 10 MB each'].map((label) => (
<span
key={label}
className="px-3 py-1 text-xs rounded-full bg-white/10 text-white/60"
>
{label}
</span>
))}
</div>
{/* ── Action buttons ── */}
{!isBlocked && (
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm mt-1">
{/* Browse files */}
<button
type="button"
onClick={openBrowse}
className="flex-1 flex items-center justify-center gap-2
px-5 py-3 rounded-xl font-medium text-sm
bg-indigo-500/20 hover:bg-indigo-500/35 border border-indigo-500/40
text-indigo-300 hover:text-indigo-200
transition-all duration-200 active:scale-95"
aria-label="Browse image files"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 7h18M3 12h18M3 17h18" />
</svg>
Browse Files
</button>
{/* Take photo — prominent on mobile */}
<button
type="button"
onClick={openCamera}
className="flex-1 flex items-center justify-center gap-2
px-5 py-3 rounded-xl font-medium text-sm
bg-cyan-500/20 hover:bg-cyan-500/35 border border-cyan-500/40
text-cyan-300 hover:text-cyan-200
transition-all duration-200 active:scale-95"
aria-label="Open camera to take a photo"
>
{/* Camera icon */}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Take Photo
</button>
</div>
)}
</div>
</div>
{/* Mobile tip */}
{!isBlocked && mobile && fileCount === 0 && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="text-center text-xs text-white/35 mt-3 px-4"
>
Tip: Capture orthographic views in order - Front, Back, Left, Right.
</motion.p>
)}
</motion.div>
);
}