From 72f93c53798e838b8158d05e7b86005524414fab Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 24 Nov 2025 21:31:18 +0800 Subject: [PATCH] feat(web): enhance file upload handling with local file support and preview --- .../MemoEditor/ActionButton/InsertMenu.tsx | 12 +- .../ActionButton/InsertMenu/useFileUpload.ts | 51 ++--- web/src/components/MemoEditor/index.tsx | 181 +++++++++--------- .../components/MemoEditor/types/context.ts | 12 +- .../memo-metadata/AttachmentCard.tsx | 46 ++--- .../memo-metadata/AttachmentList.tsx | 91 +++++---- web/src/components/memo-metadata/index.ts | 3 +- web/src/components/memo-metadata/types.ts | 119 ++++++++++++ 8 files changed, 317 insertions(+), 198 deletions(-) diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 6de3991b5..2bb2ebab6 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -3,6 +3,7 @@ import { uniqBy } from "lodash-es"; import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useContext, useState } from "react"; +import type { LocalFile } from "@/components/memo-metadata"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -13,8 +14,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; +import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; import { MemoEditorContext } from "../types"; import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog"; @@ -37,8 +37,10 @@ const InsertMenu = observer((props: Props) => { const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false); - const { fileInputRef, uploadingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((attachments: Attachment[]) => { - context.setAttachmentList([...context.attachmentList, ...attachments]); + const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => { + if (context.addLocalFiles) { + context.addLocalFiles(newFiles); + } }); const linkMemo = useLinkMemo({ @@ -53,7 +55,7 @@ const InsertMenu = observer((props: Props) => { const location = useLocation(props.location); - const isUploading = uploadingFlag || props.isUploading; + const isUploading = selectingFlag || props.isUploading; const handleLocationClick = () => { setLocationDialogOpen(true); diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts b/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts index a0c02d439..76a1995f8 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts @@ -1,43 +1,24 @@ -import mime from "mime"; import { useRef, useState } from "react"; -import { toast } from "react-hot-toast"; -import { attachmentStore } from "@/store"; -import { Attachment } from "@/types/proto/api/v1/attachment_service"; +import type { LocalFile } from "@/components/memo-metadata"; -export const useFileUpload = (onUploadComplete: (attachments: Attachment[]) => void) => { +export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { const fileInputRef = useRef(null); - const [uploadingFlag, setUploadingFlag] = useState(false); + const [selectingFlag, setSelectingFlag] = useState(false); - const handleFileInputChange = async () => { - if (!fileInputRef.current?.files || fileInputRef.current.files.length === 0 || uploadingFlag) { + const handleFileInputChange = (event?: React.ChangeEvent) => { + const files = Array.from(fileInputRef.current?.files || event?.target.files || []); + if (files.length === 0 || selectingFlag) { return; } - - setUploadingFlag(true); - const createdAttachmentList: Attachment[] = []; - - try { - 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); - } - onUploadComplete(createdAttachmentList); - } catch (error: any) { - console.error(error); - toast.error(error.details); - } finally { - setUploadingFlag(false); - } + setSelectingFlag(true); + const localFiles: LocalFile[] = files.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })); + onFilesSelected(localFiles); + setSelectingFlag(false); + // Optionally clear input value to allow re-selecting the same file + if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleUploadClick = () => { @@ -46,7 +27,7 @@ export const useFileUpload = (onUploadComplete: (attachments: Attachment[]) => v return { fileInputRef, - uploadingFlag, + selectingFlag, handleFileInputChange, handleUploadClick, }; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index e9f773722..84e77893b 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -20,6 +20,7 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t import { useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString } from "@/utils/memo"; import DateTimeInput from "../DateTimeInput"; +import type { LocalFile } from "../memo-metadata"; import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; import InsertMenu from "./ActionButton/InsertMenu"; import VisibilitySelector from "./ActionButton/VisibilitySelector"; @@ -82,6 +83,14 @@ interface State { } const MemoEditor = observer((props: Props) => { + // Local files for preview and upload + const [localFiles, setLocalFiles] = useState([]); + // Clean up blob URLs on unmount + useEffect(() => { + return () => { + localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl)); + }; + }, [localFiles]); const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props; const t = useTranslate(); const { i18n } = useTranslation(); @@ -252,6 +261,20 @@ const MemoEditor = observer((props: Props) => { })); }; + // Add local files from InsertMenu + const handleAddLocalFiles = (newFiles: LocalFile[]) => { + setLocalFiles((prev) => [...prev, ...newFiles]); + }; + + // Remove a local file (e.g. on user remove) + const handleRemoveLocalFile = (previewUrl: string) => { + setLocalFiles((prev) => { + const toRemove = prev.find((f) => f.previewUrl === previewUrl); + if (toRemove) URL.revokeObjectURL(toRemove.previewUrl); + return prev.filter((f) => f.previewUrl !== previewUrl); + }); + }; + const handleSetRelationList = (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, @@ -259,69 +282,14 @@ const MemoEditor = observer((props: Props) => { })); }; - const handleUploadResource = async (file: File) => { - setState((state) => { - return { - ...state, - isUploadingAttachment: true, - }; - }); - - const { name: filename, size, type } = file; - const buffer = new Uint8Array(await file.arrayBuffer()); - - try { - const attachment = await attachmentStore.createAttachment({ - attachment: Attachment.fromPartial({ - filename, - size, - type, - content: buffer, - }), - attachmentId: "", - }); - setState((state) => { - return { - ...state, - isUploadingAttachment: false, - }; - }); - return attachment; - } catch (error: any) { - console.error(error); - toast.error(error.details); - setState((state) => { - return { - ...state, - isUploadingAttachment: false, - }; - }); - } - }; - - const uploadMultiFiles = async (files: FileList) => { - const uploadedAttachmentList: Attachment[] = []; - for (const file of files) { - const attachment = await handleUploadResource(file); - if (attachment) { - uploadedAttachmentList.push(attachment); - if (memoName) { - await attachmentStore.updateAttachment({ - attachment: Attachment.fromPartial({ - name: attachment.name, - memo: memoName, - }), - updateMask: ["memo"], - }); - } - } - } - if (uploadedAttachmentList.length > 0) { - setState((prevState) => ({ - ...prevState, - attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList], - })); - } + // Add files to local state for preview (no upload yet) + const addFilesToLocal = (files: FileList | File[]) => { + const fileArray = Array.from(files); + const newLocalFiles: LocalFile[] = fileArray.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })); + setLocalFiles((prev) => [...prev, ...newLocalFiles]); }; const handleDropEvent = async (event: React.DragEvent) => { @@ -332,7 +300,7 @@ const MemoEditor = observer((props: Props) => { isDraggingFile: false, })); - await uploadMultiFiles(event.dataTransfer.files); + addFilesToLocal(event.dataTransfer.files); } }; @@ -360,7 +328,7 @@ const MemoEditor = observer((props: Props) => { const handlePasteEvent = async (event: React.ClipboardEvent) => { if (event.clipboardData && event.clipboardData.files.length > 0) { event.preventDefault(); - await uploadMultiFiles(event.clipboardData.files); + addFilesToLocal(event.clipboardData.files); } else if ( editorRef.current != null && editorRef.current.getSelectedContent().length != 0 && @@ -385,15 +353,35 @@ const MemoEditor = observer((props: Props) => { return; } - setState((state) => { - return { - ...state, - isRequesting: true, - }; - }); + setState((state) => ({ ...state, isRequesting: true })); const content = editorRef.current?.getContent() ?? ""; try { - // Update memo. + // 1. Upload all local files and create attachments + const newAttachments: Attachment[] = []; + if (localFiles.length > 0) { + setState((state) => ({ ...state, isUploadingAttachment: true })); + try { + for (const { file } of localFiles) { + const buffer = new Uint8Array(await file.arrayBuffer()); + const attachment = await attachmentStore.createAttachment({ + attachment: Attachment.fromPartial({ + filename: file.name, + size: file.size, + type: file.type, + content: buffer, + }), + attachmentId: "", + }); + newAttachments.push(attachment); + } + } finally { + // Always reset upload state, even on error + setState((state) => ({ ...state, isUploadingAttachment: false })); + } + } + // 2. Update attachmentList with new attachments + const allAttachments = [...state.attachmentList, ...newAttachments]; + // 3. Save memo (create or update) if (memoName) { const prevMemo = await memoStore.getOrFetchMemoByName(memoName); if (prevMemo) { @@ -410,9 +398,9 @@ const MemoEditor = observer((props: Props) => { updateMask.add("visibility"); memoPatch.visibility = state.memoVisibility; } - if (!isEqual(state.attachmentList, prevMemo.attachments)) { + if (!isEqual(allAttachments, prevMemo.attachments)) { updateMask.add("attachments"); - memoPatch.attachments = state.attachmentList; + memoPatch.attachments = allAttachments; } if (!isEqual(state.relationList, prevMemo.relations)) { updateMask.add("relations"); @@ -452,7 +440,7 @@ const MemoEditor = observer((props: Props) => { memo: Memo.fromPartial({ content, visibility: state.memoVisibility, - attachments: state.attachmentList, + attachments: allAttachments, relations: state.relationList, location: state.location, }), @@ -476,6 +464,9 @@ const MemoEditor = observer((props: Props) => { } } editorRef.current?.setContent(""); + // Clean up local files after successful save + localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl)); + setLocalFiles([]); } catch (error: any) { console.error(error); toast.error(error.details); @@ -494,14 +485,6 @@ const MemoEditor = observer((props: Props) => { }); }; - const handleCancelBtnClick = () => { - localStorage.removeItem(contentCacheKey); - - if (onCancel) { - onCancel(); - } - }; - const handleEditorFocus = () => { editorRef.current?.focus(); }; @@ -518,19 +501,18 @@ const MemoEditor = observer((props: Props) => { [i18n.language, state.isFocusMode], ); - const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting; + const allowSave = + (hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting; return ( { - setState((prevState) => ({ - ...prevState, - attachmentList, - })); - }, + setAttachmentList: handleSetAttachmentList, + addLocalFiles: handleAddLocalFiles, + removeLocalFile: handleRemoveLocalFile, + localFiles, setRelationList: (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, @@ -584,7 +566,14 @@ const MemoEditor = observer((props: Props) => { })) } /> - + {/* Show attachments and pending files together */} +
e.stopPropagation()}>
@@ -604,7 +593,15 @@ const MemoEditor = observer((props: Props) => { handleMemoVisibilityChange(visibility)} />
{props.onCancel && ( - )} diff --git a/web/src/components/MemoEditor/types/context.ts b/web/src/components/MemoEditor/types/context.ts index 8c2d81d2e..7e66a7967 100644 --- a/web/src/components/MemoEditor/types/context.ts +++ b/web/src/components/MemoEditor/types/context.ts @@ -1,6 +1,7 @@ import { createContext } from "react"; -import { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { MemoRelation } from "@/types/proto/api/v1/memo_service"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service"; +import type { MemoRelation } from "@/types/proto/api/v1/memo_service"; +import type { LocalFile } from "../../memo-metadata"; interface Context { attachmentList: Attachment[]; @@ -8,6 +9,10 @@ interface Context { setAttachmentList: (attachmentList: Attachment[]) => void; setRelationList: (relationList: MemoRelation[]) => void; memoName?: string; + // For local file upload/preview + addLocalFiles?: (files: LocalFile[]) => void; + removeLocalFile?: (previewUrl: string) => void; + localFiles?: LocalFile[]; } export const MemoEditorContext = createContext({ @@ -15,4 +20,7 @@ export const MemoEditorContext = createContext({ relationList: [], setAttachmentList: () => {}, setRelationList: () => {}, + addLocalFiles: () => {}, + removeLocalFile: () => {}, + localFiles: [], }); diff --git a/web/src/components/memo-metadata/AttachmentCard.tsx b/web/src/components/memo-metadata/AttachmentCard.tsx index 45ab49681..37d4e76e6 100644 --- a/web/src/components/memo-metadata/AttachmentCard.tsx +++ b/web/src/components/memo-metadata/AttachmentCard.tsx @@ -1,11 +1,10 @@ import { FileIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; -import { DisplayMode } from "./types"; +import type { AttachmentItem, DisplayMode } from "./types"; interface AttachmentCardProps { - attachment: Attachment; + /** Unified attachment item (uploaded or local file) */ + item: AttachmentItem; mode: DisplayMode; onRemove?: () => void; onClick?: () => void; @@ -14,32 +13,32 @@ interface AttachmentCardProps { } /** - * Shared attachment card component - * Displays thumbnails for images in both modes, with size variations + * Unified attachment card component for all file types + * Renders differently based on mode (edit/view) and file category */ -const AttachmentCard = ({ attachment, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => { - const type = getAttachmentType(attachment); - const attachmentUrl = getAttachmentUrl(attachment); - const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment); - const isMedia = type === "image/*" || type === "video/*"; +const AttachmentCard = ({ item, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => { + const { category, filename, thumbnailUrl, sourceUrl } = item; + const isMedia = category === "image" || category === "video"; - // Editor mode - compact badge style with thumbnail + // Editor mode - compact badge style with optional thumbnail if (mode === "edit") { return (
- {showThumbnail && type === "image/*" ? ( - {attachment.filename} + {showThumbnail && category === "image" && thumbnailUrl ? ( + {filename} ) : ( )} - {attachment.filename} + {filename} {onRemove && (