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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { get, post, put } from "../../api/client";
|
||||
import { get, post } from "../../api/client";
|
||||
import type {
|
||||
Project,
|
||||
FormFieldDescriptor,
|
||||
@@ -95,34 +95,9 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFilesAdded = useCallback(
|
||||
(files: PendingAttachment[]) => {
|
||||
const startIdx = attachments.length;
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
|
||||
files.forEach((f, i) => {
|
||||
const idx = startIdx + i;
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
||||
),
|
||||
);
|
||||
|
||||
upload(f.file, (progress) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadProgress: progress } : a,
|
||||
),
|
||||
);
|
||||
}).then((result) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) => (j === idx ? result : a)),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
[attachments.length, upload],
|
||||
);
|
||||
const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
const handleFileRemoved = useCallback((index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
@@ -136,24 +111,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const pending: PendingAttachment = {
|
||||
setThumbnailFile({
|
||||
file,
|
||||
objectKey: "",
|
||||
uploadProgress: 0,
|
||||
uploadStatus: "uploading",
|
||||
};
|
||||
setThumbnailFile(pending);
|
||||
|
||||
upload(file, (progress) => {
|
||||
setThumbnailFile((prev) =>
|
||||
prev ? { ...prev, uploadProgress: progress } : null,
|
||||
);
|
||||
}).then((result) => {
|
||||
setThumbnailFile(result);
|
||||
uploadStatus: "pending",
|
||||
});
|
||||
};
|
||||
input.click();
|
||||
}, [upload]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!category) {
|
||||
@@ -188,33 +154,24 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
});
|
||||
|
||||
const pn = result.part_number;
|
||||
const encodedPN = encodeURIComponent(pn);
|
||||
|
||||
// Associate uploaded attachments.
|
||||
const completed = attachments.filter(
|
||||
(a) => a.uploadStatus === "complete" && a.objectKey,
|
||||
);
|
||||
for (const att of completed) {
|
||||
// Upload attachments via direct multipart POST.
|
||||
for (const att of attachments) {
|
||||
try {
|
||||
await post(`/api/items/${encodeURIComponent(pn)}/files`, {
|
||||
object_key: att.objectKey,
|
||||
filename: att.file.name,
|
||||
content_type: att.file.type || "application/octet-stream",
|
||||
size: att.file.size,
|
||||
});
|
||||
await upload(att.file, `/api/items/${encodedPN}/files/upload`);
|
||||
} catch {
|
||||
// File association failure is non-blocking.
|
||||
// File upload failure is non-blocking.
|
||||
}
|
||||
}
|
||||
|
||||
// Set thumbnail.
|
||||
if (
|
||||
thumbnailFile?.uploadStatus === "complete" &&
|
||||
thumbnailFile.objectKey
|
||||
) {
|
||||
// Upload thumbnail via direct multipart POST.
|
||||
if (thumbnailFile) {
|
||||
try {
|
||||
await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, {
|
||||
object_key: thumbnailFile.objectKey,
|
||||
});
|
||||
await upload(
|
||||
thumbnailFile.file,
|
||||
`/api/items/${encodedPN}/thumbnail/upload`,
|
||||
);
|
||||
} catch {
|
||||
// Thumbnail failure is non-blocking.
|
||||
}
|
||||
@@ -392,21 +349,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
}}
|
||||
>
|
||||
{thumbnailFile?.uploadStatus === "complete" ? (
|
||||
{thumbnailFile ? (
|
||||
<img
|
||||
src={URL.createObjectURL(thumbnailFile.file)}
|
||||
alt="Thumbnail preview"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : thumbnailFile?.uploadStatus === "uploading" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-table)",
|
||||
color: "var(--ctp-subtext0)",
|
||||
}}
|
||||
>
|
||||
Uploading... {thumbnailFile.uploadProgress}%
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
@@ -414,7 +362,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
color: "var(--ctp-subtext0)",
|
||||
}}
|
||||
>
|
||||
Click to upload
|
||||
Click to select
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { post } from "../api/client";
|
||||
|
||||
export interface PendingAttachment {
|
||||
file: File;
|
||||
@@ -9,61 +8,65 @@ export interface PendingAttachment {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PresignResponse {
|
||||
object_key: string;
|
||||
upload_url: string;
|
||||
expires_at: 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 {
|
||||
// Get presigned URL.
|
||||
const presign = await post<PresignResponse>(
|
||||
"/api/uploads/presign",
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
// 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,
|
||||
objectKey: result.object_key ?? "",
|
||||
uploadProgress: 100,
|
||||
uploadStatus: "complete" as const,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user