Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 78 additions & 20 deletions web/src/components/MemoEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -68,6 +70,7 @@ const MemoEditor = observer((props: Props) => {
relationList: [],
location: undefined,
isUploadingAttachment: false,
uploadTasks: [],
isRequesting: false,
isComposing: false,
isDraggingFile: false,
Expand Down Expand Up @@ -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<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;
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({
Expand All @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -478,6 +515,9 @@ const MemoEditor = observer((props: Props) => {
relationList,
}));
},
uploadFiles: async (files: FileList) => {
await uploadMultiFiles(files);
},
memoName,
}}
>
Expand All @@ -497,6 +537,24 @@ const MemoEditor = observer((props: Props) => {
onCompositionEnd={handleCompositionEnd}
>
<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} />
<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()}>
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/MemoEditor/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

export const MemoEditorContext = createContext<Context>({
Expand Down