diff --git a/web/src/components/MemoEditor/ActionButton/UploadAttachmentButton.tsx b/web/src/components/MemoEditor/ActionButton/UploadAttachmentButton.tsx index 5f49f5693..d94400379 100644 --- a/web/src/components/MemoEditor/ActionButton/UploadAttachmentButton.tsx +++ b/web/src/components/MemoEditor/ActionButton/UploadAttachmentButton.tsx @@ -1,13 +1,10 @@ import { t } from "i18next"; import { LoaderIcon, PaperclipIcon } from "lucide-react"; -import mime from "mime"; import { observer } from "mobx-react-lite"; import { useContext, useRef, useState } from "react"; import toast from "react-hot-toast"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { attachmentStore } from "@/store"; -import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { MemoEditorContext } from "../types"; interface Props { @@ -39,32 +36,15 @@ const UploadAttachmentButton = observer((props: Props) => { uploadingFlag: true, }; }); - - const createdAttachmentList: Attachment[] = []; try { - if (!fileInputRef.current || !fileInputRef.current.files) { - return; - } - for (const file of fileInputRef.current.files) { - const { name: filename, size, type } = file; - const buffer = new Uint8Array(await file.arrayBuffer()); - const attachment = await attachmentStore.createAttachment({ - attachment: Attachment.fromPartial({ - filename, - size, - type: type || mime.getType(filename) || "text/plain", - content: buffer, - }), - attachmentId: "", - }); - createdAttachmentList.push(attachment); + // Delegate to editor's upload handler so progress UI is consistent + if (context.uploadFiles) { + await context.uploadFiles(fileInputRef.current.files); } } catch (error: any) { console.error(error); toast.error(error.details); } - - context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]); setState((state) => { return { ...state, diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 1e9a1de70..f666521e6 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -52,6 +52,8 @@ interface State { relationList: MemoRelation[]; location: Location | undefined; isUploadingAttachment: boolean; + // Track in-flight uploads for UI progress bars + uploadTasks: { id: string; name: string; progress: number }[]; isRequesting: boolean; isComposing: boolean; isDraggingFile: boolean; @@ -68,6 +70,7 @@ const MemoEditor = observer((props: Props) => { relationList: [], location: undefined, isUploadingAttachment: false, + uploadTasks: [], isRequesting: false, isComposing: false, isDraggingFile: false, @@ -199,16 +202,52 @@ const MemoEditor = observer((props: Props) => { }; const handleUploadResource = async (file: File) => { - setState((state) => { - return { - ...state, - isUploadingAttachment: true, - }; + const taskId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Add task entry + setState((prev) => ({ ...prev, uploadTasks: [...prev.uploadTasks, { id: taskId, name: file.name, progress: 0 }] })); + + const setTaskProgress = (progress: number) => { + setState((prev) => ({ + ...prev, + uploadTasks: prev.uploadTasks.map((t) => (t.id === taskId ? { ...t, progress } : t)), + })); + }; + const removeTask = () => { + setState((prev) => ({ + ...prev, + uploadTasks: prev.uploadTasks.filter((t) => t.id !== taskId), + })); + }; + + // Read file with progress (up to 30%) + const buffer = await new Promise((resolve, reject) => { + try { + const reader = new FileReader(); + reader.onprogress = (e) => { + if (e.lengthComputable) { + const frac = e.loaded / e.total; + setTaskProgress(Math.min(0.3, frac * 0.3)); + } + }; + reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); + reader.onload = () => { + setTaskProgress(0.3); + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.readAsArrayBuffer(file); + } catch (err) { + reject(err); + } }); - const { name: filename, size, type } = file; - const buffer = new Uint8Array(await file.arrayBuffer()); + // Simulate upload progress while the request is in-flight (30% -> 95%) + let current = 0.3; + const interval = window.setInterval(() => { + current = Math.min(0.95, current + 0.02); + setTaskProgress(current); + }, 200); + const { name: filename, size, type } = file; try { const attachment = await attachmentStore.createAttachment({ attachment: Attachment.fromPartial({ @@ -219,26 +258,22 @@ const MemoEditor = observer((props: Props) => { }), attachmentId: "", }); - setState((state) => { - return { - ...state, - isUploadingAttachment: false, - }; - }); + window.clearInterval(interval); + setTaskProgress(1); + // Remove task shortly after completion + window.setTimeout(removeTask, 500); return attachment; } catch (error: any) { + window.clearInterval(interval); console.error(error); - toast.error(error.details); - setState((state) => { - return { - ...state, - isUploadingAttachment: false, - }; - }); + toast.error(error.details ?? "Upload failed"); + // Remove task on error as well + removeTask(); } }; const uploadMultiFiles = async (files: FileList) => { + setState((prev) => ({ ...prev, isUploadingAttachment: true })); const uploadedAttachmentList: Attachment[] = []; for (const file of files) { const attachment = await handleUploadResource(file); @@ -261,6 +296,8 @@ const MemoEditor = observer((props: Props) => { attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList], })); } + // If no more tasks in-flight, clear uploading flag + setState((prev) => ({ ...prev, isUploadingAttachment: false })); }; const handleDropEvent = async (event: React.DragEvent) => { @@ -478,6 +515,9 @@ const MemoEditor = observer((props: Props) => { relationList, })); }, + uploadFiles: async (files: FileList) => { + await uploadMultiFiles(files); + }, memoName, }} > @@ -497,6 +537,24 @@ const MemoEditor = observer((props: Props) => { onCompositionEnd={handleCompositionEnd} > + {state.uploadTasks.length > 0 && ( +
+ {state.uploadTasks.map((task) => ( +
+
+ {task.name} + {Math.round(task.progress * 100)}% +
+
+
+
+
+ ))} +
+ )}
e.stopPropagation()}> diff --git a/web/src/components/MemoEditor/types/context.ts b/web/src/components/MemoEditor/types/context.ts index 8c2d81d2e..bd96db5d1 100644 --- a/web/src/components/MemoEditor/types/context.ts +++ b/web/src/components/MemoEditor/types/context.ts @@ -8,6 +8,8 @@ interface Context { setAttachmentList: (attachmentList: Attachment[]) => void; setRelationList: (relationList: MemoRelation[]) => void; memoName?: string; + // Optional: central upload handler so UI can show progress consistently + uploadFiles?: (files: FileList) => Promise; } export const MemoEditorContext = createContext({