Skip to main content

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