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
88 lines
2.3 KiB
TypeScript
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 };
|
|
}
|