mirror of
				https://github.com/usememos/memos.git
				synced 2025-06-05 22:09:59 +02:00 
			
		
		
		
	feat: support creating resource with external link (#988)
This commit is contained in:
		| @@ -27,7 +27,7 @@ type ResourceCreate struct { | ||||
| 	Filename     string `json:"filename"` | ||||
| 	Blob         []byte `json:"-"` | ||||
| 	ExternalLink string `json:"externalLink"` | ||||
| 	Type         string `json:"-"` | ||||
| 	Type         string `json:"type"` | ||||
| 	Size         int64  `json:"-"` | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										243
									
								
								web/src/components/CreateResourceDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								web/src/components/CreateResourceDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,243 @@ | ||||
| import { Button, Input, Select, Option, Typography, List, ListItem, Autocomplete } from "@mui/joy"; | ||||
| import React, { useRef, useState } from "react"; | ||||
| import { useResourceStore } from "../store/module"; | ||||
| import Icon from "./Icon"; | ||||
| import toastHelper from "./Toast"; | ||||
| import { generateDialog } from "./Dialog"; | ||||
|  | ||||
| const fileTypeAutocompleteOptions = ["image/*", "text/*", "audio/*", "video/*", "application/*"]; | ||||
|  | ||||
| interface Props extends DialogProps { | ||||
|   onCancel?: () => void; | ||||
|   onConfirm?: (resourceList: Resource[]) => void; | ||||
| } | ||||
|  | ||||
| type SelectedMode = "local-file" | "external-link"; | ||||
|  | ||||
| interface State { | ||||
|   selectedMode: SelectedMode; | ||||
|   uploadingFlag: boolean; | ||||
| } | ||||
|  | ||||
| const CreateResourceDialog: React.FC<Props> = (props: Props) => { | ||||
|   const { destroy, onCancel, onConfirm } = props; | ||||
|   const resourceStore = useResourceStore(); | ||||
|   const [state, setState] = useState<State>({ | ||||
|     selectedMode: "local-file", | ||||
|     uploadingFlag: false, | ||||
|   }); | ||||
|   const [resourceCreate, setResourceCreate] = useState<ResourceCreate>({ | ||||
|     filename: "", | ||||
|     externalLink: "", | ||||
|     type: "", | ||||
|   }); | ||||
|   const [fileList, setFileList] = useState<File[]>([]); | ||||
|   const fileInputRef = useRef<HTMLInputElement>(null); | ||||
|  | ||||
|   const handleCloseDialog = () => { | ||||
|     if (onCancel) { | ||||
|       onCancel(); | ||||
|     } | ||||
|     destroy(); | ||||
|   }; | ||||
|  | ||||
|   const handleSelectedModeChanged = (mode: "local-file" | "external-link") => { | ||||
|     setState((state) => { | ||||
|       return { | ||||
|         ...state, | ||||
|         selectedMode: mode, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleExternalLinkChanged = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     const externalLink = event.target.value; | ||||
|     setResourceCreate((state) => { | ||||
|       return { | ||||
|         ...state, | ||||
|         externalLink, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleFileNameChanged = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     const filename = event.target.value; | ||||
|     setResourceCreate((state) => { | ||||
|       return { | ||||
|         ...state, | ||||
|         filename, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleFileTypeChanged = (fileType: string) => { | ||||
|     setResourceCreate((state) => { | ||||
|       return { | ||||
|         ...state, | ||||
|         type: fileType, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleFileInputChange = async () => { | ||||
|     if (!fileInputRef.current || !fileInputRef.current.files) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const files: File[] = []; | ||||
|     for (const file of fileInputRef.current.files) { | ||||
|       files.push(file); | ||||
|     } | ||||
|     setFileList(files); | ||||
|   }; | ||||
|  | ||||
|   const allowConfirmAction = () => { | ||||
|     if (state.selectedMode === "local-file") { | ||||
|       if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) { | ||||
|         return false; | ||||
|       } | ||||
|     } else if (state.selectedMode === "external-link") { | ||||
|       if (resourceCreate.filename === "" || resourceCreate.externalLink === "" || resourceCreate.type === "") { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     return true; | ||||
|   }; | ||||
|  | ||||
|   const handleConfirmBtnClick = async () => { | ||||
|     if (state.uploadingFlag) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setState((state) => { | ||||
|       return { | ||||
|         ...state, | ||||
|         uploadingFlag: true, | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     const createdResourceList: Resource[] = []; | ||||
|     try { | ||||
|       if (state.selectedMode === "local-file") { | ||||
|         if (!fileInputRef.current || !fileInputRef.current.files) { | ||||
|           return; | ||||
|         } | ||||
|         for (const file of fileInputRef.current.files) { | ||||
|           const resource = await resourceStore.createResourceWithBlob(file); | ||||
|           createdResourceList.push(resource); | ||||
|         } | ||||
|       } else { | ||||
|         const resource = await resourceStore.createResource(resourceCreate); | ||||
|         createdResourceList.push(resource); | ||||
|       } | ||||
|     } catch (error: any) { | ||||
|       console.error(error); | ||||
|       toastHelper.error(error.response.data.message); | ||||
|     } | ||||
|  | ||||
|     if (onConfirm) { | ||||
|       onConfirm(createdResourceList); | ||||
|     } | ||||
|     destroy(); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="dialog-header-container"> | ||||
|         <p className="title-text">Create Resource</p> | ||||
|         <button className="btn close-btn" onClick={handleCloseDialog}> | ||||
|           <Icon.X /> | ||||
|         </button> | ||||
|       </div> | ||||
|       <div className="dialog-content-container !w-80"> | ||||
|         <Typography className="!mb-1" level="body2"> | ||||
|           Upload method | ||||
|         </Typography> | ||||
|         <Select | ||||
|           className="w-full mb-2" | ||||
|           onChange={(_, value) => handleSelectedModeChanged(value as SelectedMode)} | ||||
|           value={state.selectedMode} | ||||
|           startDecorator={<Icon.File className="w-4 h-auto" />} | ||||
|         > | ||||
|           <Option value="local-file">Local file</Option> | ||||
|           <Option value="external-link">External link</Option> | ||||
|         </Select> | ||||
|  | ||||
|         {state.selectedMode === "local-file" && ( | ||||
|           <> | ||||
|             <div className="w-full relative bg-blue-50 rounded-md flex flex-row justify-center items-center py-8"> | ||||
|               <label htmlFor="files" className="p-2 px-4 text-sm text-white cursor-pointer bg-blue-500 block rounded hover:opacity-80"> | ||||
|                 Choose a file... | ||||
|               </label> | ||||
|               <input | ||||
|                 className="absolute inset-0 hidden" | ||||
|                 ref={fileInputRef} | ||||
|                 onChange={handleFileInputChange} | ||||
|                 type="file" | ||||
|                 id="files" | ||||
|                 multiple={true} | ||||
|                 accept="*" | ||||
|               /> | ||||
|             </div> | ||||
|             <List size="sm"> | ||||
|               {fileList.map((file) => ( | ||||
|                 <ListItem key={file.name}>{file.name}</ListItem> | ||||
|               ))} | ||||
|             </List> | ||||
|           </> | ||||
|         )} | ||||
|  | ||||
|         {state.selectedMode === "external-link" && ( | ||||
|           <> | ||||
|             <Typography className="!mb-1" level="body2"> | ||||
|               Link | ||||
|             </Typography> | ||||
|             <Input | ||||
|               className="mb-2" | ||||
|               placeholder="File link" | ||||
|               value={resourceCreate.externalLink} | ||||
|               onChange={handleExternalLinkChanged} | ||||
|               fullWidth | ||||
|             /> | ||||
|             <Typography className="!mb-1" level="body2"> | ||||
|               File name | ||||
|             </Typography> | ||||
|             <Input className="mb-2" placeholder="File name" value={resourceCreate.filename} onChange={handleFileNameChanged} fullWidth /> | ||||
|             <Typography className="!mb-1" level="body2"> | ||||
|               Type | ||||
|             </Typography> | ||||
|             <Autocomplete | ||||
|               className="w-full" | ||||
|               size="sm" | ||||
|               placeholder="File type" | ||||
|               freeSolo={true} | ||||
|               options={fileTypeAutocompleteOptions} | ||||
|               onChange={(_, value) => handleFileTypeChanged(value || "")} | ||||
|             /> | ||||
|           </> | ||||
|         )} | ||||
|  | ||||
|         <div className="mt-2 w-full flex flex-row justify-end items-center space-x-1"> | ||||
|           <Button variant="plain" color="neutral" onClick={handleCloseDialog}> | ||||
|             Cancel | ||||
|           </Button> | ||||
|           <Button onClick={handleConfirmBtnClick} loading={state.uploadingFlag} disabled={!allowConfirmAction()}> | ||||
|             Create | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function showCreateResourceDialog(props: Omit<Props, "destroy">) { | ||||
|   generateDialog<Props>( | ||||
|     { | ||||
|       dialogName: "create-resource-dialog", | ||||
|     }, | ||||
|     CreateResourceDialog, | ||||
|     props | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default showCreateResourceDialog; | ||||
| @@ -12,6 +12,7 @@ import Selector from "./common/Selector"; | ||||
| import Editor, { EditorRefActions } from "./Editor/Editor"; | ||||
| import ResourceIcon from "./ResourceIcon"; | ||||
| import showResourcesSelectorDialog from "./ResourcesSelectorDialog"; | ||||
| import showCreateResourceDialog from "./CreateResourceDialog"; | ||||
| import "../less/memo-editor.less"; | ||||
|  | ||||
| const listItemSymbolList = ["- [ ] ", "- [x] ", "- [X] ", "* ", "- "]; | ||||
| @@ -418,33 +419,11 @@ const MemoEditor = () => { | ||||
|   }; | ||||
|  | ||||
|   const handleUploadFileBtnClick = () => { | ||||
|     const inputEl = document.createElement("input"); | ||||
|     inputEl.style.position = "fixed"; | ||||
|     inputEl.style.top = "-100vh"; | ||||
|     inputEl.style.left = "-100vw"; | ||||
|     document.body.appendChild(inputEl); | ||||
|     inputEl.type = "file"; | ||||
|     inputEl.multiple = true; | ||||
|     inputEl.accept = "*"; | ||||
|     inputEl.onchange = async () => { | ||||
|       if (!inputEl.files || inputEl.files.length === 0) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const resourceList: Resource[] = []; | ||||
|       for (const file of inputEl.files) { | ||||
|         const resource = await handleUploadResource(file); | ||||
|         if (resource) { | ||||
|           resourceList.push(resource); | ||||
|           if (editorState.editMemoId) { | ||||
|             await upsertMemoResource(editorState.editMemoId, resource.id); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       editorStore.setResourceList([...editorState.resourceList, ...resourceList]); | ||||
|       document.body.removeChild(inputEl); | ||||
|     }; | ||||
|     inputEl.click(); | ||||
|     showCreateResourceDialog({ | ||||
|       onConfirm: (resourceList) => { | ||||
|         editorStore.setResourceList([...editorState.resourceList, ...resourceList]); | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleFullscreenBtnClick = () => { | ||||
| @@ -536,7 +515,6 @@ const MemoEditor = () => { | ||||
|           </button> | ||||
|           <div className="action-btn resource-btn"> | ||||
|             <Icon.FileText className="icon-img" /> | ||||
|             <span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span> | ||||
|             <div className="resource-action-list"> | ||||
|               <div className="resource-action-item" onClick={handleUploadFileBtnClick}> | ||||
|                 <Icon.Upload className="icon-img" /> | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import Dropdown from "./common/Dropdown"; | ||||
| import { generateDialog } from "./Dialog"; | ||||
| import { showCommonDialog } from "./Dialog/CommonDialog"; | ||||
| import showPreviewImageDialog from "./PreviewImageDialog"; | ||||
| import showCreateResourceDialog from "./CreateResourceDialog"; | ||||
| import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog"; | ||||
| import "../less/resources-dialog.less"; | ||||
|  | ||||
| @@ -35,34 +36,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => { | ||||
|       }); | ||||
|   }, []); | ||||
|  | ||||
|   const handleUploadFileBtnClick = async () => { | ||||
|     const inputEl = document.createElement("input"); | ||||
|     inputEl.style.position = "fixed"; | ||||
|     inputEl.style.top = "-100vh"; | ||||
|     inputEl.style.left = "-100vw"; | ||||
|     document.body.appendChild(inputEl); | ||||
|     inputEl.type = "file"; | ||||
|     inputEl.multiple = true; | ||||
|     inputEl.accept = "*"; | ||||
|     inputEl.onchange = async () => { | ||||
|       if (!inputEl.files || inputEl.files.length === 0) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       for (const file of inputEl.files) { | ||||
|         try { | ||||
|           await resourceStore.createResourceWithBlob(file); | ||||
|         } catch (error: any) { | ||||
|           console.error(error); | ||||
|           toastHelper.error(error.response.data.message); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       document.body.removeChild(inputEl); | ||||
|     }; | ||||
|     inputEl.click(); | ||||
|   }; | ||||
|  | ||||
|   const handlePreviewBtnClick = (resource: Resource) => { | ||||
|     const resourceUrl = getResourceUrl(resource); | ||||
|     if (resource.type.startsWith("image")) { | ||||
| @@ -139,7 +112,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => { | ||||
|       <div className="dialog-content-container"> | ||||
|         <div className="w-full flex flex-row justify-between items-center"> | ||||
|           <div className="flex flex-row justify-start items-center space-x-2"> | ||||
|             <Button onClick={() => handleUploadFileBtnClick()} startDecorator={<Icon.Plus className="w-5 h-auto" />}> | ||||
|             <Button onClick={() => showCreateResourceDialog({})} startDecorator={<Icon.Plus className="w-5 h-auto" />}> | ||||
|               {t("common.create")} | ||||
|             </Button> | ||||
|           </div> | ||||
|   | ||||
							
								
								
									
										1
									
								
								web/src/types/modules/resource.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								web/src/types/modules/resource.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -17,6 +17,7 @@ interface Resource { | ||||
| interface ResourceCreate { | ||||
|   filename: string; | ||||
|   externalLink: string; | ||||
|   type: string; | ||||
| } | ||||
|  | ||||
| interface ResourcePatch { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user