Files
silo/web/src/hooks/useFileUpload.ts
Forbes 3358e7dd1c feat(web): add FileDropZone component with upload progress
New files:
- web/src/hooks/useFileUpload.ts: presigned upload hook that gets
  a presigned PUT URL from POST /api/uploads/presign then uploads
  via XMLHttpRequest for progress tracking
- web/src/components/items/FileDropZone.tsx: drag-and-drop file
  upload zone with file list, type-colored badges (CAD/PDF/IMG),
  progress bars, and remove buttons

Features:
- Dashed border drop zone with drag-over visual feedback
- Click to browse or drag files to add
- File type detection by extension with colored badges
- Upload progress bar (2px mauve) during active uploads
- Error state display per file
- Configurable accepted file types via accept prop

Closes #14
2026-02-07 10:13:18 -06:00

88 lines
2.3 KiB
TypeScript

import { useState, useCallback } from "react";
import { post } from "../api/client";
export interface PendingAttachment {
file: File;
objectKey: string;
uploadProgress: number;
uploadStatus: "pending" | "uploading" | "complete" | "error";
error?: string;
}
interface PresignResponse {
object_key: string;
upload_url: string;
expires_at: string;
}
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
(
file: File,
onProgress?: (progress: number) => void,
): Promise<PendingAttachment> => {
setUploading(true);
return (async () => {
try {
// Get presigned URL.
const presign = await post<PresignResponse>(
"/api/uploads/presign",
{
filename: file.name,
content_type: file.type || "application/octet-stream",
size: file.size,
},
);
// Upload via XMLHttpRequest for progress events.
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", presign.upload_url);
xhr.setRequestHeader(
"Content-Type",
file.type || "application/octet-stream",
);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress?.(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(file);
});
return {
file,
objectKey: presign.object_key,
uploadProgress: 100,
uploadStatus: "complete" as const,
};
} catch (e) {
return {
file,
objectKey: "",
uploadProgress: 0,
uploadStatus: "error" as const,
error: e instanceof Error ? e.message : "Upload failed",
};
} finally {
setUploading(false);
}
})();
},
[],
);
return { upload, uploading };
}