From 3f237386b212a3ef05e5e9351706ea17c212755e Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Fri, 10 Sep 2021 13:04:49 +0200 Subject: [PATCH] #98 - Drag and drop functionality added --- CHANGELOG.md | 1 + package-lock.json | 34 ++++++++++++ package.json | 1 + src/commands/Dashboard.ts | 68 +++++++++++++++++++++--- src/helpers/decodeBase64Image.ts | 13 +++++ src/pagesView/DashboardMessage.ts | 1 + src/pagesView/components/Media/Item.tsx | 18 +++++-- src/pagesView/components/Media/Media.tsx | 45 ++++++++++++++-- 8 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/helpers/decodeBase64Image.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae2741b..beafba36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#89](https://github.com/estruyf/vscode-front-matter/issues/89): Clear filter, sorting, and grouping button added - [#90](https://github.com/estruyf/vscode-front-matter/issues/90): Refactoring to use Recoil state management - [#91](https://github.com/estruyf/vscode-front-matter/issues/91): Support image previews from content folders +- [#98](https://github.com/estruyf/vscode-front-matter/issues/98): Add drag and drop support for media upload in a folder ## [3.0.2] - 2021-08-31 diff --git a/package-lock.json b/package-lock.json index b0dde401..d19c5193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -872,6 +872,12 @@ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", "dev": true }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "dev": true + }, "autoprefixer": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.3.2.tgz", @@ -2326,6 +2332,23 @@ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", "dev": true }, + "file-selector": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz", + "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==", + "dev": true, + "requires": { + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4796,6 +4819,17 @@ "scheduler": "^0.20.1" } }, + "react-dropzone": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.3.4.tgz", + "integrity": "sha512-B1nzNRZ4F1cnrfEC0T6KXeBN1mCPinu4JCoTrp7NjB+442KSPxqfDrw41QIA2kAwlYs1+wj/0BTedeM5hc2+xw==", + "dev": true, + "requires": { + "attr-accept": "^2.2.1", + "file-selector": "^0.2.2", + "prop-types": "^15.7.2" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 63ae6aaf..d0b0cdee 100644 --- a/package.json +++ b/package.json @@ -425,6 +425,7 @@ "postcss-loader": "4.3.0", "react": "17.0.1", "react-dom": "17.0.1", + "react-dropzone": "^11.3.4", "recoil": "^0.4.1", "rimraf": "^3.0.2", "style-loader": "2.0.0", diff --git a/src/commands/Dashboard.ts b/src/commands/Dashboard.ts index 70b6d2c3..a4ea54de 100644 --- a/src/commands/Dashboard.ts +++ b/src/commands/Dashboard.ts @@ -1,7 +1,7 @@ import { SETTINGS_CONTENT_STATIC_FOLDERS, SETTING_DATE_FIELD, SETTING_SEO_DESCRIPTION_FIELD, SETTINGS_DASHBOARD_OPENONSTART } from './../constants/settings'; import { ArticleHelper } from './../helpers/ArticleHelper'; import { dirname, extname, join } from "path"; -import { existsSync, statSync } from "fs"; +import { existsSync, statSync, writeFileSync } from "fs"; import { commands, Uri, ViewColumn, Webview, WebviewPanel, window, workspace, env } from "vscode"; import { SettingsHelper } from '../helpers'; import { TaxonomyType } from '../models'; @@ -19,12 +19,14 @@ import { parseJSON } from 'date-fns'; import { ViewType } from '../pagesView/state'; import { WebviewHelper } from '@estruyf/vscode'; import { MediaInfo, MediaPaths } from './../models/MediaPaths'; +import { decodeBase64Image } from '../helpers/decodeBase64Image'; export class Dashboard { private static webview: WebviewPanel | null = null; private static isDisposed: boolean = true; private static media: MediaInfo[] = []; + private static timers: { [folder: string]: any } = {}; /**  * Init the dashboard @@ -142,6 +144,9 @@ export class Dashboard { Dashboard.media = []; Dashboard.getMedia(0, msg?.data?.folder); break; + case DashboardMessage.uploadMedia: + Dashboard.saveFile(msg?.data); + break; } }); } @@ -223,7 +228,7 @@ export class Dashboard { } // Filter the media - let files = Dashboard.media; + let files: MediaInfo[] = Dashboard.media; if (folder) { files = files.filter(f => f.fsPath.includes(folder)); } @@ -233,10 +238,16 @@ export class Dashboard { // Get media set files = files.slice(page * 16, ((page + 1) * 16)); - files = files.map((file) => ({ - ...file, - stats: statSync(file.fsPath) - })); + files = files.map((file) => { + try { + return { + ...file, + stats: statSync(file.fsPath) + }; + } catch (e) { + return {...file, stats: undefined}; + } + }).filter(f => f.stats !== undefined); const folders = [...new Set(Dashboard.media.map((file) => { let relFolderPath = wsFolder ? file.fsPath.substring(wsFolder.fsPath.length + 1) : file.fsPath; @@ -347,6 +358,51 @@ export class Dashboard { } as MediaInfo)); } + /** + * Save the dropped file in the current folder + * @param fileData + */ + private static async saveFile({fileName, contents, folder}: { fileName: string; contents: string; folder: string | null }) { + if (fileName && contents) { + const wsFolder = Folders.getWorkspaceFolder(); + const config = SettingsHelper.getConfig(); + const staticFolder = config.get(SETTINGS_CONTENT_STATIC_FOLDERS); + const wsPath = wsFolder ? wsFolder.fsPath : ""; + let absFolderPath = join(wsPath, staticFolder || "", folder || ""); + + if (!existsSync(absFolderPath)) { + absFolderPath = join(wsPath, folder || ""); + } + + if (!existsSync(absFolderPath)) { + Notifications.error(`We couldn't find your selected folder.`); + return; + } + + const staticPath = join(absFolderPath, fileName); + const imgData = decodeBase64Image(contents); + + if (imgData) { + writeFileSync(staticPath, imgData.data); + Notifications.info(`File ${fileName} uploaded to: ${staticFolder}/${folder}`); + + const folderPath = `${staticFolder}/${folder}`; + if (Dashboard.timers[folderPath]) { + clearTimeout(Dashboard.timers[folderPath]); + delete Dashboard.timers[folderPath]; + } + + Dashboard.timers[folderPath] = setTimeout(() => { + Dashboard.media = []; + Dashboard.getMedia(0, folder || ""); + delete Dashboard.timers[folderPath]; + }, 500); + } else { + Notifications.error(`Something went wrong uploading ${fileName}`); + } + } + } + /** * Post data to the dashboard * @param msg diff --git a/src/helpers/decodeBase64Image.ts b/src/helpers/decodeBase64Image.ts new file mode 100644 index 00000000..2e3e3993 --- /dev/null +++ b/src/helpers/decodeBase64Image.ts @@ -0,0 +1,13 @@ +export const decodeBase64Image = (dataString: string) => { + const matches = dataString.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); + let response: any = {}; + + if (matches?.length !== 3) { + return null; + } + + response.type = matches[1]; + response.data = Buffer.from(matches[2], 'base64'); + + return response; +} diff --git a/src/pagesView/DashboardMessage.ts b/src/pagesView/DashboardMessage.ts index 0fa3b3eb..9c512b8b 100644 --- a/src/pagesView/DashboardMessage.ts +++ b/src/pagesView/DashboardMessage.ts @@ -10,4 +10,5 @@ export enum DashboardMessage { getMedia = 'getMedia', copyToClipboard = 'copyToClipboard', refreshMedia = 'refreshMedia', + uploadMedia = 'uploadMedia', } \ No newline at end of file diff --git a/src/pagesView/components/Media/Item.tsx b/src/pagesView/components/Media/Item.tsx index 3c53d5cd..4ba9c6f8 100644 --- a/src/pagesView/components/Media/Item.tsx +++ b/src/pagesView/components/Media/Item.tsx @@ -1,5 +1,5 @@ import { Messenger } from '@estruyf/vscode/dist/client'; -import { ClipboardCopyIcon, PhotographIcon } from '@heroicons/react/outline'; +import { ClipboardCopyIcon, PhotographIcon, TrashIcon } from '@heroicons/react/outline'; import { basename, dirname } from 'path'; import * as React from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; @@ -45,6 +45,10 @@ export const Item: React.FunctionComponent = ({media}: React.PropsWi Messenger.send(DashboardMessage.copyToClipboard, parseWinPath(relPath) || ""); }; + const deleteMedia = () => { + + }; + const calculateSize = () => { if (media?.stats?.size) { const size = media.stats.size / (1024*1024); @@ -62,22 +66,28 @@ export const Item: React.FunctionComponent = ({media}: React.PropsWi return (
  • -
    -
    +
    +

    {basename(parseWinPath(media.fsPath) || "")} diff --git a/src/pagesView/components/Media/Media.tsx b/src/pagesView/components/Media/Media.tsx index a0d68e0d..e18a5eab 100644 --- a/src/pagesView/components/Media/Media.tsx +++ b/src/pagesView/components/Media/Media.tsx @@ -1,16 +1,20 @@ import { Messenger } from '@estruyf/vscode/dist/client'; import { EventData } from '@estruyf/vscode/dist/models'; +import { UploadIcon } from '@heroicons/react/outline'; import * as React from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { MediaInfo, MediaPaths } from '../../../models/MediaPaths'; import { DashboardCommand } from '../../DashboardCommand'; -import { LoadingAtom, MediaFoldersAtom, MediaTotalAtom, SettingsSelector } from '../../state'; +import { LoadingAtom, MediaFoldersAtom, MediaTotalAtom, SelectedMediaFolderSelector, SettingsSelector } from '../../state'; import { Header } from '../Header'; import { Spinner } from '../Spinner'; import { SponsorMsg } from '../SponsorMsg'; import { Item } from './Item'; import { Lightbox } from './Lightbox'; import { List } from './List'; +import { useDropzone } from 'react-dropzone' +import { useCallback } from 'react'; +import { DashboardMessage } from '../../DashboardMessage'; export interface IMediaProps {} @@ -18,11 +22,34 @@ export const LIMIT = 16; export const Media: React.FunctionComponent = (props: React.PropsWithChildren) => { const settings = useRecoilValue(SettingsSelector); + const selectedFolder = useRecoilValue(SelectedMediaFolderSelector); const [ media, setMedia ] = React.useState([]); const [ , setTotal ] = useRecoilState(MediaTotalAtom); const [ , setFolders ] = useRecoilState(MediaFoldersAtom); const [ loading, setLoading ] = useRecoilState(LoadingAtom); + const onDrop = useCallback((acceptedFiles: File[]) => { + acceptedFiles.forEach((file) => { + const reader = new FileReader(); + + reader.onload = () => { + const contents = reader.result; + Messenger.send(DashboardMessage.uploadMedia, { + fileName: file.name, + contents, + folder: selectedFolder + }); + }; + + reader.readAsDataURL(file) + }); + }, [selectedFolder]); + + const {getRootProps, isDragActive} = useDropzone({ + onDrop, + accept: 'image/*' + }); + const messageListener = (message: MessageEvent>) => { if (message.data.command === DashboardCommand.media) { setLoading(false); @@ -30,7 +57,7 @@ export const Media: React.FunctionComponent = (props: React.PropsWi setTotal(message.data.data.total); setFolders(message.data.data.folders); } - } + }; React.useEffect(() => { Messenger.listen(messageListener); @@ -45,7 +72,19 @@ export const Media: React.FunctionComponent = (props: React.PropsWi

    -
    +
    + + { + isDragActive && ( +
    + +

    + {selectedFolder ? `Upload to ${selectedFolder}` : `No folder selected, files you drop will be added to the ${settings?.staticFolder || "public"} folder.`} +

    +
    + ) + } + { media.map((file) => (