Bart Dorsey

Drag-and-Drop Upload

UploadForm builds on the simple upload with drag-and-drop support and an image preview before uploading. It uses the same API layer and form submission logic, adding three new pieces: drag event handling, URL.createObjectURL() for preview, and the DataTransfer API to bridge dropped files into the form.


State

frontend/src/features/upload/UploadForm.tsx#L6-L9 on GitHub

const [error, setError] = useState("");
const [preview, setPreview] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

Drag Event Handlers

frontend/src/features/upload/UploadForm.tsx#L54-L78 on GitHub

function handleDrop(e: React.DragEvent<HTMLDivElement>) {
    e.preventDefault();
    setDragActive(false);
    const file = e.dataTransfer.files?.[0];

    if (file) {
        if (
            file.type !== "image/jpeg" &&
            file.type !== "image/png" &&
            file.type !== "image/gif" &&
            file.type !== "image/webp"
        ) {
            setError("Unsupported image type");
            setPreview(null);
            return;
        }

        setError("");
        setPreview(URL.createObjectURL(file));

        // Bridge the dropped file into the hidden form input
        if (fileInputRef.current) {
            const dt = new DataTransfer();
            dt.items.add(file);
            fileInputRef.current.files = dt.files;
        }
    }
}

The three drag events work together:

Event Handler Purpose
onDragOver e.preventDefault(); setDragActive(true) Allows drop (browser default cancels it), activates drop zone UI
onDragLeave setDragActive(false) Resets UI when file is dragged back out
onDrop handleDrop Validates file, sets preview, bridges into form

Image Preview with URL.createObjectURL()

setPreview(URL.createObjectURL(file));

URL.createObjectURL() creates a temporary blob: URL that points to the file in memory. This lets you show the image immediately without uploading it first.

The URL exists only in the current browser session. To free the memory when the component unmounts:

useEffect(() => {
    return () => {
        if (preview) URL.revokeObjectURL(preview);
    };
}, [preview]);

Bridging Drag-and-Drop into the Form

The form’s action handler receives a FormData built from the form’s <input> elements. A file dropped onto the div never touches the <input>, so the form wouldn’t include it — unless we manually set it:

frontend/src/features/upload/UploadForm.tsx#L72-L75 on GitHub

const dt = new DataTransfer();
dt.items.add(file);
fileInputRef.current.files = dt.files;

DataTransfer is a browser API normally used for drag-and-drop. Here we use it purely to create a FileList we can assign to the input’s .files property (which is read-only except for this assignment pattern). After this, the hidden input contains the dropped file, and form submission works identically to the click-to-browse path.


The Drop Zone UI

frontend/src/features/upload/UploadForm.tsx#L116-L164 on GitHub

<div
    className={`border-2 border-dashed rounded-lg p-8 cursor-pointer transition-all ${
        dragActive
            ? "border-blue-500 bg-blue-50"
            : "border-gray-300 hover:border-blue-400"
    }`}
    onDrop={handleDrop}
    onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
    onDragLeave={(e) => { e.preventDefault(); setDragActive(false); }}
    onClick={() => fileInputRef.current?.click()}
>
    <p>{dragActive ? "Drop your image here!" : "Drag & drop or click to browse"}</p>

    {/* Hidden input — opened programmatically on click */}
    <input
        ref={fileInputRef}
        type="file"
        name="photo"
        className="hidden"
        accept="image/jpeg,image/png,image/gif,image/webp"
        onChange={handleFileChange}
    />

    {preview && (
        <img src={preview} alt="Preview" className="max-h-64 rounded-lg mx-auto mt-6" />
    )}
</div>

Clicking the div calls fileInputRef.current?.click(), which programmatically opens the file picker. This gives a custom-styled drop zone while keeping the native file input for accessibility and form semantics.


Handling Click-to-Browse

When the user selects a file via the picker (not drag-and-drop), the onChange handler updates the preview:

frontend/src/features/upload/UploadForm.tsx#L36-L52 on GitHub

function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (file) {
        if (/* type check */) {
            setError("Unsupported image type");
            setPreview(null);
            return;
        }
        setError("");
        setPreview(URL.createObjectURL(file));
    }
}

No DataTransfer bridging is needed here — the file is already in the input.


Submit Button State

frontend/src/features/upload/UploadForm.tsx#L192-L198 on GitHub

<button type="submit" disabled={!preview}>
    {preview ? "Upload Photo" : "Select a photo first"}
</button>

The button is disabled until a file is selected or dropped. This prevents empty form submissions and gives the user a clear signal about what to do next.


Complete Component

frontend/src/features/upload/UploadForm.tsx on GitHub

export default function UploadForm() {
    const [error, setError] = useState("");
    const [preview, setPreview] = useState<string | null>(null);
    const [dragActive, setDragActive] = useState(false);
    const fileInputRef = useRef<HTMLInputElement>(null);
    const navigate = useNavigate();

    async function handleSubmit(formData: FormData) {
        setError("");
        const file = formData.get("photo");
        if (file instanceof File) {
            if (
                file.type !== "image/jpeg" &&
                file.type !== "image/png" &&
                file.type !== "image/gif" &&
                file.type !== "image/webp"
            ) {
                setError("Unsupported image type");
                return;
            }
            const result = await uploadPhoto(formData);
            if (result.success) {
                navigate("/");
            } else {
                setError(result.error);
            }
        }
    }

    function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
        const file = e.target.files?.[0];
        if (file) {
            setError("");
            setPreview(URL.createObjectURL(file));
        }
    }

    function handleDrop(e: React.DragEvent<HTMLDivElement>) {
        e.preventDefault();
        setDragActive(false);
        const file = e.dataTransfer.files?.[0];
        if (file) {
            setError("");
            setPreview(URL.createObjectURL(file));
            if (fileInputRef.current) {
                const dt = new DataTransfer();
                dt.items.add(file);
                fileInputRef.current.files = dt.files;
            }
        }
    }

    return (
        <div className="max-w-2xl mx-auto">
            <form className="space-y-6" action={handleSubmit}>
                {error && <div className="error">{error}</div>}

                <div
                    className={`border-2 border-dashed rounded-lg p-8 cursor-pointer ${
                        dragActive ? "border-blue-500 bg-blue-50" : "border-gray-300"
                    }`}
                    onDrop={handleDrop}
                    onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
                    onDragLeave={(e) => { e.preventDefault(); setDragActive(false); }}
                    onClick={() => fileInputRef.current?.click()}
                >
                    <p>{dragActive ? "Drop here!" : "Drag & drop or click to browse"}</p>
                    <input
                        ref={fileInputRef}
                        type="file"
                        name="photo"
                        className="hidden"
                        accept="image/jpeg,image/png,image/gif,image/webp"
                        onChange={handleFileChange}
                    />
                    {preview && <img src={preview} alt="Preview" className="max-h-64 rounded-lg mx-auto mt-6" />}
                </div>

                <button type="submit" disabled={!preview}>
                    {preview ? "Upload Photo" : "Select a photo first"}
                </button>
            </form>
        </div>
    );
}

Extension Points

Progress tracking

const [progress, setProgress] = useState(0);
// Use XMLHttpRequest with the `progress` event, or fetch with a ReadableStream

Multiple files

// Add `multiple` to the file input
<input type="file" name="photos" multiple ... />

// Handle arrays in the drop handler
const files = Array.from(e.dataTransfer.files);

Resize before upload

// Use the Canvas API to resize images client-side before sending
async function resizeImage(file: File, maxWidth: number): Promise<File> {
    const canvas = document.createElement("canvas");
    // draw image, scale, export as blob
}