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:
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -314,3 +316,188 @@ func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageBackend returns the configured storage backend name, defaulting to "minio".
|
||||||
|
func (s *Server) storageBackend() string {
|
||||||
|
if s.cfg != nil && s.cfg.Storage.Backend != "" {
|
||||||
|
return s.cfg.Storage.Backend
|
||||||
|
}
|
||||||
|
return "minio"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.
|
||||||
|
func (s *Server) HandleUploadItemFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
if s.storage == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (max 500MB)
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate permanent key
|
||||||
|
fileID := uuid.New().String()
|
||||||
|
permanentKey := fmt.Sprintf("items/%s/files/%s/%s", item.ID, fileID, header.Filename)
|
||||||
|
|
||||||
|
// Write directly to storage
|
||||||
|
result, err := s.storage.Put(ctx, permanentKey, file, header.Size, contentType)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to upload file")
|
||||||
|
writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DB record
|
||||||
|
itemFile := &db.ItemFile{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: header.Filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
Size: result.Size,
|
||||||
|
ObjectKey: permanentKey,
|
||||||
|
StorageBackend: s.storageBackend(),
|
||||||
|
}
|
||||||
|
if err := s.itemFiles.Create(ctx, itemFile); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to create item file record")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save file record")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info().
|
||||||
|
Str("part_number", partNumber).
|
||||||
|
Str("file_id", itemFile.ID).
|
||||||
|
Str("filename", header.Filename).
|
||||||
|
Int64("size", result.Size).
|
||||||
|
Msg("file uploaded to item")
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, itemFileToResponse(itemFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUploadItemThumbnail accepts a multipart file upload and sets it as the item thumbnail.
|
||||||
|
func (s *Server) HandleUploadItemThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
if s.storage == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (max 10MB for thumbnails)
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "image/png"
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailKey := fmt.Sprintf("items/%s/thumbnail.png", item.ID)
|
||||||
|
|
||||||
|
if _, err := s.storage.Put(ctx, thumbnailKey, file, header.Size, contentType); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to upload thumbnail")
|
||||||
|
writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store thumbnail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.items.SetThumbnailKey(ctx, item.ID, thumbnailKey); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to update thumbnail key")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save thumbnail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDownloadItemFile streams an item file attachment to the client.
|
||||||
|
func (s *Server) HandleDownloadItemFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
fileID := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
if s.storage == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil || item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := s.itemFiles.Get(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "File not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.ItemID != item.ID {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "File not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := s.storage.Get(ctx, file.ObjectKey)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("key", file.ObjectKey).Msg("failed to get file")
|
||||||
|
writeError(w, http.StatusInternalServerError, "download_failed", "Failed to retrieve file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", file.ContentType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename))
|
||||||
|
if file.Size > 0 {
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(file.Size, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(w, reader)
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
||||||
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
||||||
r.Get("/files", server.HandleListItemFiles)
|
r.Get("/files", server.HandleListItemFiles)
|
||||||
|
r.Get("/files/{fileId}/download", server.HandleDownloadItemFile)
|
||||||
r.Get("/file", server.HandleDownloadLatestFile)
|
r.Get("/file", server.HandleDownloadLatestFile)
|
||||||
r.Get("/file/{revision}", server.HandleDownloadFile)
|
r.Get("/file/{revision}", server.HandleDownloadFile)
|
||||||
r.Get("/bom", server.HandleGetBOM)
|
r.Get("/bom", server.HandleGetBOM)
|
||||||
@@ -199,8 +200,10 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
|
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
|
||||||
r.Post("/file", server.HandleUploadFile)
|
r.Post("/file", server.HandleUploadFile)
|
||||||
r.Post("/files", server.HandleAssociateItemFile)
|
r.Post("/files", server.HandleAssociateItemFile)
|
||||||
|
r.Post("/files/upload", server.HandleUploadItemFile)
|
||||||
r.Delete("/files/{fileId}", server.HandleDeleteItemFile)
|
r.Delete("/files/{fileId}", server.HandleDeleteItemFile)
|
||||||
r.Put("/thumbnail", server.HandleSetItemThumbnail)
|
r.Put("/thumbnail", server.HandleSetItemThumbnail)
|
||||||
|
r.Post("/thumbnail/upload", server.HandleUploadItemThumbnail)
|
||||||
r.Post("/bom", server.HandleAddBOMEntry)
|
r.Post("/bom", server.HandleAddBOMEntry)
|
||||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||||
r.Post("/bom/merge", server.HandleMergeBOM)
|
r.Post("/bom/merge", server.HandleMergeBOM)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { get, post, put } from "../../api/client";
|
import { get, post } from "../../api/client";
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
FormFieldDescriptor,
|
FormFieldDescriptor,
|
||||||
@@ -95,34 +95,9 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFilesAdded = useCallback(
|
const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
|
||||||
(files: PendingAttachment[]) => {
|
|
||||||
const startIdx = attachments.length;
|
|
||||||
setAttachments((prev) => [...prev, ...files]);
|
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 handleFileRemoved = useCallback((index: number) => {
|
const handleFileRemoved = useCallback((index: number) => {
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||||
@@ -136,24 +111,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const pending: PendingAttachment = {
|
setThumbnailFile({
|
||||||
file,
|
file,
|
||||||
objectKey: "",
|
objectKey: "",
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
uploadStatus: "uploading",
|
uploadStatus: "pending",
|
||||||
};
|
|
||||||
setThumbnailFile(pending);
|
|
||||||
|
|
||||||
upload(file, (progress) => {
|
|
||||||
setThumbnailFile((prev) =>
|
|
||||||
prev ? { ...prev, uploadProgress: progress } : null,
|
|
||||||
);
|
|
||||||
}).then((result) => {
|
|
||||||
setThumbnailFile(result);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}, [upload]);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!category) {
|
if (!category) {
|
||||||
@@ -188,33 +154,24 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pn = result.part_number;
|
const pn = result.part_number;
|
||||||
|
const encodedPN = encodeURIComponent(pn);
|
||||||
|
|
||||||
// Associate uploaded attachments.
|
// Upload attachments via direct multipart POST.
|
||||||
const completed = attachments.filter(
|
for (const att of attachments) {
|
||||||
(a) => a.uploadStatus === "complete" && a.objectKey,
|
|
||||||
);
|
|
||||||
for (const att of completed) {
|
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(pn)}/files`, {
|
await upload(att.file, `/api/items/${encodedPN}/files/upload`);
|
||||||
object_key: att.objectKey,
|
|
||||||
filename: att.file.name,
|
|
||||||
content_type: att.file.type || "application/octet-stream",
|
|
||||||
size: att.file.size,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// File association failure is non-blocking.
|
// File upload failure is non-blocking.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set thumbnail.
|
// Upload thumbnail via direct multipart POST.
|
||||||
if (
|
if (thumbnailFile) {
|
||||||
thumbnailFile?.uploadStatus === "complete" &&
|
|
||||||
thumbnailFile.objectKey
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, {
|
await upload(
|
||||||
object_key: thumbnailFile.objectKey,
|
thumbnailFile.file,
|
||||||
});
|
`/api/items/${encodedPN}/thumbnail/upload`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Thumbnail failure is non-blocking.
|
// Thumbnail failure is non-blocking.
|
||||||
}
|
}
|
||||||
@@ -392,21 +349,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
backgroundColor: "var(--ctp-mantle)",
|
backgroundColor: "var(--ctp-mantle)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thumbnailFile?.uploadStatus === "complete" ? (
|
{thumbnailFile ? (
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(thumbnailFile.file)}
|
src={URL.createObjectURL(thumbnailFile.file)}
|
||||||
alt="Thumbnail preview"
|
alt="Thumbnail preview"
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -414,7 +362,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Click to upload
|
Click to select
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { post } from "../api/client";
|
|
||||||
|
|
||||||
export interface PendingAttachment {
|
export interface PendingAttachment {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -9,42 +8,38 @@ export interface PendingAttachment {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PresignResponse {
|
interface UploadResponse {
|
||||||
object_key: string;
|
id?: number;
|
||||||
upload_url: string;
|
object_key?: string;
|
||||||
expires_at: 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() {
|
export function useFileUpload() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const upload = useCallback(
|
const upload = useCallback(
|
||||||
(
|
(
|
||||||
file: File,
|
file: File,
|
||||||
|
url: string,
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
): Promise<PendingAttachment> => {
|
): Promise<PendingAttachment> => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
return (async () => {
|
return (async () => {
|
||||||
try {
|
try {
|
||||||
// Get presigned URL.
|
const form = new FormData();
|
||||||
const presign = await post<PresignResponse>(
|
form.append("file", file);
|
||||||
"/api/uploads/presign",
|
|
||||||
{
|
|
||||||
filename: file.name,
|
|
||||||
content_type: file.type || "application/octet-stream",
|
|
||||||
size: file.size,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upload via XMLHttpRequest for progress events.
|
const result = await new Promise<UploadResponse>(
|
||||||
await new Promise<void>((resolve, reject) => {
|
(resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("PUT", presign.upload_url);
|
xhr.open("POST", url);
|
||||||
xhr.setRequestHeader(
|
xhr.withCredentials = true;
|
||||||
"Content-Type",
|
|
||||||
file.type || "application/octet-stream",
|
|
||||||
);
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
@@ -53,17 +48,25 @@ export function useFileUpload() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
|
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.onerror = () => reject(new Error("Upload failed"));
|
||||||
xhr.send(file);
|
xhr.send(form);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
objectKey: presign.object_key,
|
objectKey: result.object_key ?? "",
|
||||||
uploadProgress: 100,
|
uploadProgress: 100,
|
||||||
uploadStatus: "complete" as const,
|
uploadStatus: "complete" as const,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user