Chapter 43: File Uploads
File uploads are a common requirement — avatars, attachments, documents. This chapter covers client-side handling, upload progress, validation, and server-side processing.
Client-Side File Handling
File Input Component
// shared/components/forms/file-upload.tsx
import { useCallback, useState, useRef } from "react";
interface FileUploadProps {
accept?: string;
maxSize?: number; // bytes
multiple?: boolean;
onUpload: (files: File[]) => Promise<void>;
}
export function FileUpload({
accept = "image/*",
maxSize = 5 * 1024 * 1024, // 5MB default
multiple = false,
onUpload,
}: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const validateFiles = useCallback((files: FileList | File[]): File[] => {
const validated: File[] = [];
for (const file of Array.from(files)) {
if (file.size > maxSize) {
setError(`${file.name} exceeds ${maxSize / 1024 / 1024}MB limit`);
continue;
}
validated.push(file);
}
return validated;
}, [maxSize]);
const handleFiles = useCallback(async (fileList: FileList | null) => {
if (!fileList) return;
setError(null);
const valid = validateFiles(fileList);
if (valid.length > 0) {
await onUpload(valid);
}
}, [validateFiles, onUpload]);
return (
<div
className={cn(
"rounded-lg border-2 border-dashed p-8 text-center transition-colors",
isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25",
)}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
handleFiles(e.dataTransfer.files);
}}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
<p className="text-sm text-muted-foreground">
Drag and drop files here, or{" "}
<button
type="button"
className="text-primary underline"
onClick={() => inputRef.current?.click()}
>
browse
</button>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
);
}
Upload with Progress Tracking
// shared/hooks/use-file-upload.ts
interface UploadState {
progress: number;
isUploading: boolean;
error: string | null;
}
export function useFileUpload() {
const [state, setState] = useState<UploadState>({
progress: 0,
isUploading: false,
error: null,
});
const upload = useCallback(async (file: File, url: string) => {
setState({ progress: 0, isUploading: true, error: null });
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setState((s) => ({ ...s, progress: Math.round((e.loaded / e.total) * 100) }));
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
setState({ progress: 100, isUploading: false, error: null });
const response = JSON.parse(xhr.responseText);
resolve(response.url);
} else {
const error = `Upload failed: ${xhr.status}`;
setState({ progress: 0, isUploading: false, error });
reject(new Error(error));
}
});
xhr.addEventListener("error", () => {
setState({ progress: 0, isUploading: false, error: "Network error" });
reject(new Error("Network error"));
});
const formData = new FormData();
formData.append("file", file);
xhr.open("POST", url);
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
xhr.send(formData);
});
}, []);
return { ...state, upload };
}
Image Preview
function AvatarUpload({ currentUrl, onUpload }: AvatarUploadProps) {
const [preview, setPreview] = useState<string | null>(null);
const { progress, isUploading, upload } = useFileUpload();
const handleFile = async (files: File[]) => {
const file = files[0];
if (!file) return;
// Show preview immediately
const reader = new FileReader();
reader.onload = (e) => setPreview(e.target?.result as string);
reader.readAsDataURL(file);
// Upload
const url = await upload(file, "/api/uploads/avatar");
onUpload(url);
};
return (
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={preview ?? currentUrl} />
<AvatarFallback>?</AvatarFallback>
</Avatar>
<div>
<FileUpload accept="image/*" maxSize={2 * 1024 * 1024} onUpload={handleFile} />
{isUploading && (
<div className="mt-2">
<Progress value={progress} className="h-2" />
</div>
)}
</div>
</div>
);
}
Server-Side Upload Handling
// API route for file uploads
app.post("/api/uploads/:type", authMiddleware, async (req, res) => {
const file = req.file; // From multer or similar middleware
// Validate
const maxSizes: Record<string, number> = {
avatar: 2 * 1024 * 1024, // 2MB
attachment: 10 * 1024 * 1024, // 10MB
document: 25 * 1024 * 1024, // 25MB
};
if (file.size > (maxSizes[req.params.type] ?? 0)) {
return res.status(400).json({ error: { message: "File too large" } });
}
// Upload to S3/MinIO
const key = `${req.params.type}/${req.user.id}/${Date.now()}-${file.originalname}`;
await s3.putObject({
Bucket: "taskforge-uploads",
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
});
const url = `${process.env.CDN_URL}/${key}`;
res.json({ data: { url, key } });
});
Summary
- ✅ Drag-and-drop file upload component with validation
- ✅ Progress tracking with XMLHttpRequest upload events
- ✅ Image preview using FileReader before upload completes
- ✅ Size and type validation on both client and server
- ✅ S3/MinIO storage for production file management