From 9a506bd9dac0e429d1135a55ef268cc9b345dc2f Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Fri, 11 Mar 2022 15:17:00 -0800 Subject: [PATCH] client: drag and drop file uploading --- .../drag-and-drop/drag-and-drop.module.css | 40 ++++ .../new-post/drag-and-drop/index.tsx | 178 ++++++++++++++++++ .../components/{post => new-post}/index.tsx | 5 +- .../{post => new-post}/post.module.css | 0 .../{post => new-post}/title/index.tsx | 0 client/package.json | 1 + client/pages/new.tsx | 4 +- client/yarn.lock | 26 +++ 8 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 client/components/new-post/drag-and-drop/drag-and-drop.module.css create mode 100644 client/components/new-post/drag-and-drop/index.tsx rename client/components/{post => new-post}/index.tsx (96%) rename client/components/{post => new-post}/post.module.css (100%) rename client/components/{post => new-post}/title/index.tsx (100%) diff --git a/client/components/new-post/drag-and-drop/drag-and-drop.module.css b/client/components/new-post/drag-and-drop/drag-and-drop.module.css new file mode 100644 index 00000000..c9b16951 --- /dev/null +++ b/client/components/new-post/drag-and-drop/drag-and-drop.module.css @@ -0,0 +1,40 @@ +.container { + display: flex; + flex-direction: column; +} + +.container ul { + margin: 0; + margin-top: var(--gap-double); +} + +.dropzone { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + border-width: 2px; + border-radius: 2px; + border-style: dashed; + outline: none; + transition: border 0.24s ease-in-out; +} + +.error { + color: red; + font-size: 0.8rem; + transition: border 0.24s ease-in-out; + border: 2px solid red; + border-radius: 2px; + padding: 20px; +} + +.error > li:before { + content: ""; +} + +.error ul { + margin: 0; + padding-left: var(--gap-double); +} diff --git a/client/components/new-post/drag-and-drop/index.tsx b/client/components/new-post/drag-and-drop/index.tsx new file mode 100644 index 00000000..9e5eeab4 --- /dev/null +++ b/client/components/new-post/drag-and-drop/index.tsx @@ -0,0 +1,178 @@ +import { Button, Text, useTheme, useToasts } from '@geist-ui/core' +import { useCallback, useEffect } from 'react' +import { useDropzone } from 'react-dropzone' +import styles from './drag-and-drop.module.css' +import { Document } from '../' +import generateUUID from '../../../lib/generate-uuid' +import { XCircle } from '@geist-ui/icons' +const allowedFileTypes = [ + 'application/json', + 'application/x-javascript', + 'application/xhtml+xml', + 'application/xml', + 'text/xml', + 'text/plain', + 'text/html', + 'text/csv', + 'text/tab-separated-values', + 'text/x-c', + 'text/x-c++', + 'text/x-csharp', + 'text/x-java', + 'text/x-javascript', + 'text/x-php', + 'text/x-python', + 'text/x-ruby', + 'text/x-scala', + 'text/x-swift', + 'text/x-typescript', + 'text/x-vb', + 'text/x-vbscript', + 'text/x-yaml', + 'text/x-c++', + 'text/x-c#', + 'text/mathml', + 'text/x-markdown', + 'text/markdown', +] + +// Files with no extension can't be easily detected as plain-text, +// so instead of allowing all of them we'll just allow common ones +const allowedFileNames = [ + 'Makefile', + 'README', + 'Dockerfile', + 'Jenkinsfile', + 'LICENSE', + '.env', + '.gitignore', + '.gitattributes', + '.env.example', + '.env.development', + '.env.production', + '.env.test', + '.env.staging', + '.env.development.local', + 'yarn.lock', +] + +const allowedFileExtensions = [ + 'json', + 'js', + 'jsx', + 'ts', + 'tsx', + 'c', + 'cpp', + 'c++', + 'c#', + 'java', + 'php', + 'py', + 'rb', + 'scala', + 'swift', + 'vb', + 'vbscript', + 'yaml', + 'less', + 'stylus', + 'styl', + 'sass', + 'scss', + 'lock', + 'md', + 'markdown', + 'txt', + 'html', + 'htm', + 'css', + 'csv', + 'log', + 'sql', + 'xml', + 'webmanifest', +] + +// TODO: this shouldn't need to know about docs +function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) { + const { palette } = useTheme() + const onDrop = useCallback((acceptedFiles) => { + acceptedFiles.forEach((file: File) => { + const reader = new FileReader() + + reader.onabort = () => console.log('file reading was aborted') + reader.onerror = () => console.log('file reading has failed') + reader.onload = () => { + const content = reader.result as string + if (docs.length === 1 && docs[0].content === '') { + setDocs([{ + title: file.name, + content, + id: generateUUID() + }]) + } else { + setDocs([...docs, { + title: file.name, + content, + id: generateUUID() + }]) + } + } + reader.readAsText(file) + }) + + }, [docs, setDocs]) + + const validator = (file: File) => { + // TODO: make this configurable + const maxFileSize = 1000000; + if (file.size > maxFileSize) { + return { + code: 'file-too-big', + message: 'File is too big. Maximum file size is ' + (maxFileSize).toFixed(2) + ' MB.', + } + } + // We initially try to use the browser provided mime type, and then fall back to file names and finally extensions + if (allowedFileTypes.includes(file.type) || allowedFileNames.includes(file.name) || allowedFileExtensions.includes(file.name?.split('.').pop() || '')) { + return null + } else { + return { + code: "not-plain-text", + message: `Only plain text files are allowed.` + }; + } + } + + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, validator }) + + const fileRejectionItems = fileRejections.map(({ file, errors }) => ( +
  • + {file.name}: + +
  • + )); + + return ( +
    +
    + + {!isDragActive && Drag some files here, or click to select files} + {isDragActive && Release to drop the files here} +
    + {fileRejections.length > 0 && } +
    + ) +} + +export default FileDropzone \ No newline at end of file diff --git a/client/components/post/index.tsx b/client/components/new-post/index.tsx similarity index 96% rename from client/components/post/index.tsx rename to client/components/new-post/index.tsx index b9ba49fe..aaaa1cf5 100644 --- a/client/components/post/index.tsx +++ b/client/components/new-post/index.tsx @@ -3,9 +3,11 @@ import { useRouter } from 'next/router'; import { useCallback, useState } from 'react' import generateUUID from '../../lib/generate-uuid'; import Document from '../document'; +import FileDropzone from './drag-and-drop'; import styles from './post.module.css' import Title from './title'; -type Document = { + +export type Document = { title: string content: string id: string @@ -63,6 +65,7 @@ const Post = () => { return (
    + <FileDropzone docs={docs} setDocs={setDocs} /> { docs.map(({ id }) => { const doc = docs.find((doc) => doc.id === id) diff --git a/client/components/post/post.module.css b/client/components/new-post/post.module.css similarity index 100% rename from client/components/post/post.module.css rename to client/components/new-post/post.module.css diff --git a/client/components/post/title/index.tsx b/client/components/new-post/title/index.tsx similarity index 100% rename from client/components/post/title/index.tsx rename to client/components/new-post/title/index.tsx diff --git a/client/package.json b/client/package.json index a3a3fdd4..71d47a7a 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "react": "17.0.2", "react-debounce-render": "^8.0.2", "react-dom": "17.0.2", + "react-dropzone": "^12.0.4", "react-loading-skeleton": "^3.0.3", "react-markdown": "^8.0.0", "react-syntax-highlighter": "^15.4.5", diff --git a/client/pages/new.tsx b/client/pages/new.tsx index d05e8b9d..8a7f0b63 100644 --- a/client/pages/new.tsx +++ b/client/pages/new.tsx @@ -1,6 +1,6 @@ import Head from 'next/head' import styles from '../styles/Home.module.css' -import HomeComponent from '../components/post' +import NewPost from '../components/new-post' import { Page } from '@geist-ui/core' import useSignedIn from '../lib/hooks/use-signed-in' import Header from '../components/header' @@ -24,7 +24,7 @@ const Home = ({ theme, changeTheme }: ThemeProps) => { </Page.Header> <Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}> - {isSignedIn && <HomeComponent />} + {isSignedIn && <NewPost />} </Page.Content> </Page > ) diff --git a/client/yarn.lock b/client/yarn.lock index 50e23b31..336c1760 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -371,6 +371,11 @@ ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= +attr-accept@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + axe-core@^4.3.5: version "4.4.1" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413" @@ -935,6 +940,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.4.0.tgz#59ec4f27aa5baf0841e9c6385c8386bef4d18b17" + integrity sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg== + dependencies: + tslib "^2.0.3" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -2253,6 +2265,15 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-dropzone@^12.0.4: + version "12.0.4" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-12.0.4.tgz#b88eeaa2c7118f7fd042404682b17a1d466f2fcf" + integrity sha512-fcqHEYe1MzAghU6/Hz86lHDlBNsA+lO48nAcm7/wA+kIzwS6uuJbUG33tBZjksj7GAZ1iUQ6NHwjUURPmSGang== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.4.0" + prop-types "^15.8.1" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -2670,6 +2691,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.3: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"