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
91 lines
2.4 KiB
TypeScript
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 };
|
|
}
|