feat(web): add upload progress UI for attachments in MemoEditor\n\n- Centralize uploads via MemoEditor context to show progress consistently\n- Show per-file progress bars during file selection, drag-and-drop, and paste\n- Disable Save while uploads are in-flight\n- Keep server logic unchanged\n\nRefs #5020

pull/5075/head
Ector09 1 month ago
parent 3be1b3a1e3
commit 222c3bb448

@ -1,13 +1,10 @@
import { t } from "i18next"; import { t } from "i18next";
import { LoaderIcon, PaperclipIcon } from "lucide-react"; import { LoaderIcon, PaperclipIcon } from "lucide-react";
import mime from "mime";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useContext, useRef, useState } from "react"; import { useContext, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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"; import { MemoEditorContext } from "../types";
interface Props { interface Props {
@ -39,32 +36,15 @@ const UploadAttachmentButton = observer((props: Props) => {
uploadingFlag: true, uploadingFlag: true,
}; };
}); });
const createdAttachmentList: Attachment[] = [];
try { try {
if (!fileInputRef.current || !fileInputRef.current.files) { // Delegate to editor's upload handler so progress UI is consistent
return; if (context.uploadFiles) {
} await context.uploadFiles(fileInputRef.current.files);
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);
} }
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
} }
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
setState((state) => { setState((state) => {
return { return {
...state, ...state,

@ -52,6 +52,8 @@ interface State {
relationList: MemoRelation[]; relationList: MemoRelation[];
location: Location | undefined; location: Location | undefined;
isUploadingAttachment: boolean; isUploadingAttachment: boolean;
// Track in-flight uploads for UI progress bars
uploadTasks: { id: string; name: string; progress: number }[];
isRequesting: boolean; isRequesting: boolean;
isComposing: boolean; isComposing: boolean;
isDraggingFile: boolean; isDraggingFile: boolean;
@ -68,6 +70,7 @@ const MemoEditor = observer((props: Props) => {
relationList: [], relationList: [],
location: undefined, location: undefined,
isUploadingAttachment: false, isUploadingAttachment: false,
uploadTasks: [],
isRequesting: false, isRequesting: false,
isComposing: false, isComposing: false,
isDraggingFile: false, isDraggingFile: false,
@ -199,16 +202,52 @@ const MemoEditor = observer((props: Props) => {
}; };
const handleUploadResource = async (file: File) => { const handleUploadResource = async (file: File) => {
setState((state) => { const taskId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return { // Add task entry
...state, setState((prev) => ({ ...prev, uploadTasks: [...prev.uploadTasks, { id: taskId, name: file.name, progress: 0 }] }));
isUploadingAttachment: true,
}; 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<Uint8Array>((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; // Simulate upload progress while the request is in-flight (30% -> 95%)
const buffer = new Uint8Array(await file.arrayBuffer()); 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 { try {
const attachment = await attachmentStore.createAttachment({ const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({ attachment: Attachment.fromPartial({
@ -219,26 +258,22 @@ const MemoEditor = observer((props: Props) => {
}), }),
attachmentId: "", attachmentId: "",
}); });
setState((state) => { window.clearInterval(interval);
return { setTaskProgress(1);
...state, // Remove task shortly after completion
isUploadingAttachment: false, window.setTimeout(removeTask, 500);
};
});
return attachment; return attachment;
} catch (error: any) { } catch (error: any) {
window.clearInterval(interval);
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details ?? "Upload failed");
setState((state) => { // Remove task on error as well
return { removeTask();
...state,
isUploadingAttachment: false,
};
});
} }
}; };
const uploadMultiFiles = async (files: FileList) => { const uploadMultiFiles = async (files: FileList) => {
setState((prev) => ({ ...prev, isUploadingAttachment: true }));
const uploadedAttachmentList: Attachment[] = []; const uploadedAttachmentList: Attachment[] = [];
for (const file of files) { for (const file of files) {
const attachment = await handleUploadResource(file); const attachment = await handleUploadResource(file);
@ -261,6 +296,8 @@ const MemoEditor = observer((props: Props) => {
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList], attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
})); }));
} }
// If no more tasks in-flight, clear uploading flag
setState((prev) => ({ ...prev, isUploadingAttachment: false }));
}; };
const handleDropEvent = async (event: React.DragEvent) => { const handleDropEvent = async (event: React.DragEvent) => {
@ -478,6 +515,9 @@ const MemoEditor = observer((props: Props) => {
relationList, relationList,
})); }));
}, },
uploadFiles: async (files: FileList) => {
await uploadMultiFiles(files);
},
memoName, memoName,
}} }}
> >
@ -497,6 +537,24 @@ const MemoEditor = observer((props: Props) => {
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
> >
<Editor ref={editorRef} {...editorConfig} /> <Editor ref={editorRef} {...editorConfig} />
{state.uploadTasks.length > 0 && (
<div className="w-full mt-2 space-y-2">
{state.uploadTasks.map((task) => (
<div key={task.id} className="w-full">
<div className="flex items-center justify-between text-xs opacity-70 mb-1">
<span className="truncate max-w-[70%]">{task.name}</span>
<span className="tabular-nums">{Math.round(task.progress * 100)}%</span>
</div>
<div className="w-full h-1.5 rounded bg-muted">
<div
className="h-1.5 rounded bg-primary transition-[width] duration-200"
style={{ width: `${Math.max(2, Math.round(task.progress * 100))}%` }}
/>
</div>
</div>
))}
</div>
)}
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} /> <AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} /> <RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}> <div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}>

@ -8,6 +8,8 @@ interface Context {
setAttachmentList: (attachmentList: Attachment[]) => void; setAttachmentList: (attachmentList: Attachment[]) => void;
setRelationList: (relationList: MemoRelation[]) => void; setRelationList: (relationList: MemoRelation[]) => void;
memoName?: string; memoName?: string;
// Optional: central upload handler so UI can show progress consistently
uploadFiles?: (files: FileList) => Promise<void>;
} }
export const MemoEditorContext = createContext<Context>({ export const MemoEditorContext = createContext<Context>({

Loading…
Cancel
Save