Files
silo/web/src/hooks/useFileUpload.ts
forbes-0023 ffa01ebeb7 feat(api): direct multipart upload endpoints for filesystem backend
Add three new endpoints that bypass the MinIO presigned URL flow:
- POST /api/items/{pn}/files/upload — multipart file upload
- POST /api/items/{pn}/thumbnail/upload — multipart thumbnail upload
- GET /api/items/{pn}/files/{fileId}/download — stream file download

Rewrite frontend upload flow: files are held in browser memory on drop
and uploaded directly after item creation via multipart POST. The old
presign+associate endpoints remain for MinIO backward compatibility.

Closes #129
2026-02-17 13:04:44 -06:00

91 lines
2.4 KiB
TypeScript

import { useState, useCallback } from "react";
export interface PendingAttachment {
file: File;
objectKey: string;
uploadProgress: number;
uploadStatus: "pending" | "uploading" | "complete" | "error";
error?: string;
}
interface UploadResponse {
id?: number;
object_key?: string;
}
/**
* Hook for uploading files via direct multipart POST.
*
* Callers provide the target URL; the hook builds a FormData body and uses
* XMLHttpRequest so that upload progress events are available.
*/
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
(
file: File,
url: string,
onProgress?: (progress: number) => void,
): Promise<PendingAttachment> => {
setUploading(true);
return (async () => {
try {
const form = new FormData();
form.append("file", file);
const result = await new Promise<UploadResponse>(
(resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress?.(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as UploadResponse);
} catch {
resolve({});
}
} else {
reject(new Error(`Upload failed: HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(form);
},
);
return {
file,
objectKey: result.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 };
}