├── .gitignore ├── .markdownlint.json ├── .vscode └── settings.json ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.js ├── FileRow.js ├── FileUploader.js ├── FileUploaderScreen.js ├── Review.js ├── SVGScaleLoop.js ├── Spinner.js ├── images │ ├── 121911.jpg │ ├── adventure-background-blur-891252.jpg │ ├── art-arts-and-crafts-bright-1124884.jpg │ └── jade_input_bg.jpg ├── index.js ├── serviceWorker.js ├── styles.css └── useApp.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD003": { "style": "atx_closed" }, 4 | "MD007": { "indent": 4 }, 5 | "MD013": false, 6 | "no-hard-tabs": false 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Upload App 2 | 3 | https://medium.com/@jsmanifest/build-a-modern-customized-file-uploading-user-interface-in-react-with-plain-css-8a78bc92963a 4 | 5 | https://jsmanifest.com/build-complex-custom-file-uploading-ui-in-react-with-plain-css/ 6 | 7 | https://dev.to/jsmanifest/build-a-complex-customized-file-uploading-user-interface-in-react-with-plain-css-5539 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upload-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reach/router": "^1.2.1", 7 | "classnames": "^2.2.6", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-icons": "^3.7.0", 11 | "react-md-spinner": "^1.0.0", 12 | "react-scripts": "3.0.1", 13 | "react-transition-group": "^4.1.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsmanifest/upload-app/fab299e5307681a8ca74866839b84055c3985ebb/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from '@reach/router' 3 | import { MdArrowDownward } from 'react-icons/md' 4 | import useApp from './useApp' 5 | import FileUploader from './FileUploader' 6 | import FileUploaderScreen from './FileUploaderScreen' 7 | import FileRow from './FileRow' 8 | import SVGScaleLoop from './SVGScaleLoop' 9 | import './styles.css' 10 | 11 | const App = ({ children }) => { 12 | const inputRef = React.createRef() 13 | const { 14 | files, 15 | pending, 16 | next, 17 | uploading, 18 | uploaded, 19 | uploadError, 20 | status, 21 | onSubmit, 22 | onChange, 23 | triggerInput, 24 | getFileUploaderProps, 25 | } = useApp({ inputRef }) 26 | 27 | const initialFileUploaderProps = getFileUploaderProps({ 28 | triggerInput: status === 'IDLE' ? triggerInput : undefined, 29 | onChange: status === 'IDLE' ? onChange : undefined, 30 | }) 31 | 32 | return ( 33 |
34 |
35 | 36 | 44 | 45 |
46 |
47 | {files.map(({ id, ...rest }, index) => ( 48 | 55 | ))} 56 |
57 | {status === 'FILES_UPLOADED' && ( 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 | )} 67 | {children} 68 |
69 | ) 70 | } 71 | 72 | export default App 73 | -------------------------------------------------------------------------------- /src/FileRow.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Spinner from './Spinner' 3 | 4 | const getReadableSizeFromBytes = (bytes) => { 5 | const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 6 | let l = 0 7 | let n = parseInt(bytes, 10) || 0 8 | while (n >= 1024 && ++l) n /= 1024 9 | // include a decimal point and a tenths-place digit if presenting 10 | // less than ten of KB or greater units 11 | return n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + units[l] 12 | } 13 | 14 | const Caption = ({ children, label, block, ...rest }) => ( 15 |
19 | {label}: 20 | {children} 21 |
22 | ) 23 | 24 | const FileRow = ({ isUploaded, isUploading, file, src, id, index }) => ( 25 |
31 | {isUploading && ( 32 | 33 | Uploading... 34 | 35 | )} 36 |
37 | 38 | 39 | {file.name} 40 | 41 |
42 |
43 | 44 | {getReadableSizeFromBytes(file.size)} 45 | 46 |
47 |
48 | ) 49 | 50 | const isEqual = (currProps, nextProps) => { 51 | if (currProps.index !== nextProps.index) { 52 | return false 53 | } 54 | if (currProps.isUploaded !== nextProps.isUploaded) { 55 | return false 56 | } 57 | if (currProps.isUploading !== nextProps.isUploading) { 58 | return false 59 | } 60 | return true 61 | } 62 | 63 | export default React.memo(FileRow, isEqual) 64 | -------------------------------------------------------------------------------- /src/FileUploader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const FileUploader = ({ 4 | children, 5 | triggerInput, 6 | inputRef, 7 | status, 8 | onChange, 9 | }) => { 10 | let hiddenInputStyle = {} 11 | // If user passes in children, display children and hide input. 12 | if (children) { 13 | hiddenInputStyle = { 14 | position: 'absolute', 15 | top: '-9999px', 16 | } 17 | } 18 | 19 | return ( 20 |
24 | 32 |
{children}
33 |
34 | ) 35 | } 36 | 37 | export default FileUploader 38 | -------------------------------------------------------------------------------- /src/FileUploaderScreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import cx from 'classnames' 3 | import FileUploader from './FileUploader' 4 | import fileUploadBg from './images/jade_input_bg.jpg' 5 | import Spinner from './Spinner' 6 | import artsCrafts from './images/art-arts-and-crafts-bright-1124884.jpg' 7 | import adventureBeginsBg from './images/adventure-background-blur-891252.jpg' 8 | import errorSrc from './images/121911.jpg' 9 | 10 | const Init = () => ( 11 |
12 |

Upload Files

13 | Click here to select your files 14 |
15 | ) 16 | 17 | const Loaded = ({ total, getFileUploaderProps }) => ( 18 |
19 |

{total} files loaded

20 |
What would you like to do?
21 |
22 | 23 | 24 | 25 |
26 | 27 |
28 |
29 |
30 | ) 31 | 32 | const Pending = ({ files, pending }) => { 33 | const total = files.length 34 | const remaining = Math.abs(pending.length - total) 35 | return ( 36 |
37 | 38 | Uploading {remaining} of{' '} 39 | {total} files 40 | 41 |
42 | ) 43 | } 44 | 45 | const Success = () => ( 46 |
47 |
48 |

Congratulations!

49 | You uploaded your files. Get some rest. 50 |
51 | Look for the arrow! 52 |
53 |
54 | ) 55 | 56 | const Error = ({ uploadError }) => ( 57 |
58 |

59 | An error occurred! 60 |
61 | {uploadError && uploadError.message} 62 |

63 |
64 | ) 65 | 66 | const FileUploaderScreen = ({ 67 | status, 68 | files, 69 | pending, 70 | uploadError, 71 | triggerInput, 72 | getFileUploaderProps, 73 | }) => { 74 | let src 75 | switch (status) { 76 | case 'IDLE': 77 | src = fileUploadBg 78 | break 79 | case 'LOADED': 80 | case 'PENDING': 81 | src = artsCrafts 82 | break 83 | case 'FILES_UPLOADED': 84 | src = adventureBeginsBg 85 | break 86 | case 'UPLOAD_ERROR': 87 | src = errorSrc 88 | break 89 | default: 90 | src = fileUploadBg 91 | break 92 | } 93 | return ( 94 |
95 | {status === 'IDLE' && } 96 | {status === 'LOADED' && ( 97 | 102 | )} 103 | {status === 'PENDING' && } 104 | {status === 'FILES_UPLOADED' && } 105 | {status === 'UPLOAD_ERROR' && } 106 |
115 |
116 | ) 117 | } 118 | 119 | export default FileUploaderScreen 120 | -------------------------------------------------------------------------------- /src/Review.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Spinner from './Spinner' 3 | 4 | const Review = ({ children }) => Hold on for just a moment... 5 | 6 | export default Review 7 | -------------------------------------------------------------------------------- /src/SVGScaleLoop.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const SVGScaleLoop = ({ className, children, ...props }) => ( 4 | 5 | {children} 6 | 7 | ) 8 | 9 | export default SVGScaleLoop 10 | -------------------------------------------------------------------------------- /src/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MDSpinner from 'react-md-spinner' 3 | import cx from 'classnames' 4 | 5 | const Spinner = ({ 6 | children, 7 | containerProps, 8 | spinnerProps, 9 | xs, 10 | sm, 11 | center, 12 | }) => ( 13 |
19 |
20 |
21 | 26 |
27 |

32 | {children} 33 |

34 |
35 |
36 | ) 37 | 38 | export default Spinner 39 | -------------------------------------------------------------------------------- /src/images/121911.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsmanifest/upload-app/fab299e5307681a8ca74866839b84055c3985ebb/src/images/121911.jpg -------------------------------------------------------------------------------- /src/images/adventure-background-blur-891252.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsmanifest/upload-app/fab299e5307681a8ca74866839b84055c3985ebb/src/images/adventure-background-blur-891252.jpg -------------------------------------------------------------------------------- /src/images/art-arts-and-crafts-bright-1124884.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsmanifest/upload-app/fab299e5307681a8ca74866839b84055c3985ebb/src/images/art-arts-and-crafts-bright-1124884.jpg -------------------------------------------------------------------------------- /src/images/jade_input_bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsmanifest/upload-app/fab299e5307681a8ca74866839b84055c3985ebb/src/images/jade_input_bg.jpg -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Router } from '@reach/router' 4 | import App from './App' 5 | import Review from './Review' 6 | import * as serviceWorker from './serviceWorker' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | , 13 | document.getElementById('root'), 14 | ) 15 | 16 | // If you want your app to work offline and load faster, you can change 17 | // unregister() to register() below. Note this comes with some pitfalls. 18 | // Learn more about service workers: https://bit.ly/CRA-PWA 19 | serviceWorker.unregister() 20 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 12px; 3 | background: #171c1f; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 14 | monospace; 15 | } 16 | 17 | h1, 18 | h2, 19 | h3, 20 | h4, 21 | h5, 22 | h6 { 23 | margin: 0; 24 | font-weight: 500; 25 | } 26 | 27 | button { 28 | transition: all 0.2s ease-out; 29 | margin: 4px; 30 | cursor: pointer; 31 | background: rgb(116, 20, 63); 32 | border: 0; 33 | color: #fff; 34 | min-width: 90px; 35 | padding: 8px 12px; 36 | outline: none; 37 | text-transform: uppercase; 38 | letter-spacing: 1.3px; 39 | font-size: 0.6rem; 40 | border: 1px solid #fff; 41 | } 42 | 43 | button:hover { 44 | background: none; 45 | color: #fff; 46 | } 47 | 48 | button:active { 49 | background: #fa3402; 50 | } 51 | 52 | .form { 53 | max-width: 400px; 54 | margin: auto; 55 | } 56 | 57 | .uploader { 58 | display: flex; 59 | justify-content: center; 60 | flex-direction: column; 61 | width: 100%; 62 | box-sizing: border-box; 63 | } 64 | 65 | .uploader-input { 66 | position: relative; 67 | transition: all 3s ease-out; 68 | box-sizing: border-box; 69 | width: 100%; 70 | height: 150px; 71 | border: 1px solid rgb(194, 92, 67); 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | color: #fff; 76 | } 77 | 78 | .uploader-input:hover { 79 | filter: brightness(100%) contrast(90%); 80 | border: 1px solid rgb(223, 80, 44); 81 | } 82 | 83 | .uploader-input:active { 84 | filter: brightness(70%); 85 | } 86 | 87 | .uploader-input-content { 88 | color: #fff; 89 | height: 100%; 90 | display: flex; 91 | justify-content: center; 92 | align-items: center; 93 | } 94 | 95 | .uploader-overlay { 96 | transition: all 2s ease-out; 97 | width: 100%; 98 | height: 100%; 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | bottom: 0; 104 | z-index: -1; 105 | background-size: cover; 106 | } 107 | 108 | .uploader-overlay:hover { 109 | filter: brightness(75%); 110 | } 111 | 112 | .uploader-overlay:active { 113 | filter: brightness(40%); 114 | } 115 | 116 | .file-list { 117 | font-size: 0.75rem; 118 | } 119 | 120 | .file-row { 121 | position: relative; 122 | transition: all 0.15s ease-in; 123 | display: flex; 124 | align-items: center; 125 | justify-content: space-between; 126 | padding: 6px 0; 127 | max-height: 50px; 128 | animation: fade 0.6s ease-in; 129 | } 130 | 131 | .file-row:hover { 132 | opacity: 0.7 !important; 133 | } 134 | 135 | @keyframes fade { 136 | 0% { 137 | opacity: 0; 138 | } 139 | 100% { 140 | opacity: 1; 141 | } 142 | } 143 | 144 | .file-row-thumbarea { 145 | position: relative; 146 | display: flex; 147 | align-items: center; 148 | justify-content: space-between; 149 | flex-grow: 1; 150 | } 151 | 152 | .file-row-thumbarea img { 153 | transition: all 0.15s ease-out; 154 | border: 1px solid rgb(170, 26, 110); 155 | width: 50px; 156 | height: 50px; 157 | object-fit: cover; 158 | } 159 | 160 | .file-row-filename { 161 | flex-grow: 1; 162 | padding: 0 12px; 163 | font-size: 0.7rem; 164 | } 165 | 166 | .file-row-additional-info { 167 | opacity: 0.7; 168 | } 169 | 170 | .file-row-filesize { 171 | font-style: italic; 172 | font-size: 0.7rem; 173 | padding: 3px 6px; 174 | border-radius: 6px; 175 | width: 90px; 176 | text-align: center; 177 | border: 1px solid rgb(112, 78, 58); 178 | animation: border-glow 2s ease-in forwards; 179 | } 180 | 181 | @keyframes border-glow { 182 | 0% { 183 | border: 1px solid rgb(94, 68, 54); 184 | } 185 | 100% { 186 | border: 1px solid rgb(255, 74, 2); 187 | } 188 | } 189 | 190 | .loaded { 191 | text-align: center; 192 | } 193 | 194 | .loaded h2 { 195 | margin: 0; 196 | } 197 | 198 | .loaded-actions { 199 | display: flex; 200 | justify-content: center; 201 | align-items: center; 202 | } 203 | 204 | .pending { 205 | transition: all 1s ease-in; 206 | } 207 | 208 | .pending span.text-attention { 209 | margin: auto 3px; 210 | } 211 | 212 | .success-container { 213 | padding: 7px; 214 | color: #fff; 215 | text-align: center; 216 | display: flex; 217 | justify-content: center; 218 | align-items: center; 219 | height: 100%; 220 | } 221 | 222 | .success-container h2 { 223 | margin: 0; 224 | } 225 | 226 | .next-step { 227 | transition: all 0.5s ease-out; 228 | padding: 12px; 229 | display: flex; 230 | flex-direction: column; 231 | justify-content: center; 232 | align-items: center; 233 | animation: fade 2s ease-out forwards; 234 | } 235 | 236 | .next-step button { 237 | background: rgb(40, 59, 66); 238 | } 239 | 240 | .next-step button:hover { 241 | background: rgb(18, 48, 68); 242 | } 243 | 244 | .next-step svg { 245 | color: #fff; 246 | width: 25px; 247 | height: 25px; 248 | margin-bottom: 6px; 249 | } 250 | 251 | .spinner-container { 252 | position: relative; 253 | box-sizing: border-box; 254 | padding: 15px; 255 | text-align: center; 256 | display: flex; 257 | justify-content: center; 258 | } 259 | 260 | .spinner { 261 | color: #fff; 262 | margin-top: 18px; 263 | } 264 | 265 | .spinner-xs { 266 | margin-top: 4px; 267 | } 268 | 269 | .cursor-pointer { 270 | cursor: pointer; 271 | } 272 | 273 | .brightness100 { 274 | filter: brightness(100%); 275 | } 276 | 277 | .brightness75 { 278 | filter: brightness(75%); 279 | } 280 | 281 | .brightness50 { 282 | filter: brightness(50%); 283 | } 284 | 285 | .opacity05 { 286 | opacity: 0.25; 287 | } 288 | 289 | .grayscale { 290 | filter: grayscale(100%) brightness(60%); 291 | } 292 | 293 | .flex-center { 294 | position: absolute; 295 | top: 0; 296 | right: 0; 297 | bottom: 0; 298 | left: 0; 299 | width: 100%; 300 | height: 100%; 301 | display: flex; 302 | justify-content: center; 303 | align-items: center; 304 | } 305 | 306 | .arrow-down { 307 | animation: scale-loop 1.2s infinite linear; 308 | transition: all 0.2s ease-out; 309 | transform: scale(1); 310 | color: rgb(82, 255, 97) !important; 311 | } 312 | 313 | @keyframes scale-loop { 314 | 0% { 315 | transform: scale(1); 316 | } 317 | 50% { 318 | transform: scale(1.45); 319 | } 320 | 100% { 321 | transform: scale(1); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/useApp.js: -------------------------------------------------------------------------------- 1 | import { useReducer, useRef, useEffect } from 'react' 2 | 3 | const api = { 4 | uploadFile() { 5 | return new Promise((resolve) => { 6 | setTimeout(() => { 7 | resolve() 8 | }, 550) 9 | }) 10 | }, 11 | } 12 | 13 | const logUploadedFile = (num, color = 'green') => { 14 | const msg = `%cUploaded ${num} files.` 15 | const style = `color:${color};font-weight:bold;` 16 | console.log(msg, style) 17 | } 18 | 19 | // Constants 20 | const IDLE = 'IDLE' 21 | const LOADED = 'LOADED' 22 | const INIT = 'INIT' 23 | const PENDING = 'PENDING' 24 | const FILES_UPLOADED = 'FILES_UPLOADED' 25 | const UPLOAD_ERROR = 'UPLOAD_ERROR' 26 | 27 | const initialState = { 28 | files: [], 29 | fileNames: [], 30 | pending: [], 31 | next: null, 32 | uploading: false, 33 | uploaded: {}, 34 | uploadError: null, 35 | status: IDLE, 36 | } 37 | 38 | const reducer = (state, action) => { 39 | switch (action.type) { 40 | case 'load': 41 | return { 42 | ...state, 43 | files: action.files, 44 | fileNames: action.fileNames, 45 | status: LOADED, 46 | } 47 | case 'submit': 48 | return { ...state, uploading: true, pending: state.files, status: INIT } 49 | case 'next': 50 | return { 51 | ...state, 52 | next: action.next, 53 | status: PENDING, 54 | } 55 | case 'file-uploaded': 56 | return { 57 | ...state, 58 | next: null, 59 | pending: action.pending, 60 | uploaded: { 61 | ...state.uploaded, 62 | [action.prev.id]: action.prev.file, 63 | }, 64 | } 65 | case 'files-uploaded': 66 | return { ...state, uploading: false, status: FILES_UPLOADED } 67 | case 'set-upload-error': 68 | return { ...state, uploadError: action.error, status: UPLOAD_ERROR } 69 | default: 70 | return state 71 | } 72 | } 73 | 74 | const useApp = ({ inputRef }) => { 75 | const [state, dispatch] = useReducer(reducer, initialState) 76 | 77 | const onSubmit = (e) => { 78 | e.preventDefault() 79 | if (state.files.length) { 80 | dispatch({ type: 'submit' }) 81 | } else { 82 | window.alert("You don't have any files loaded.") 83 | } 84 | } 85 | 86 | const onChange = (e) => { 87 | e.persist() 88 | if (e.target.files.length) { 89 | const arrFiles = Array.from(e.target.files) 90 | const fileNames = [] 91 | const files = arrFiles.reduce((acc, file) => { 92 | if (!state.fileNames.includes(file.name)) { 93 | fileNames.push(file.name) 94 | const src = window.URL.createObjectURL(file) 95 | acc.push({ file, id: file.name, src }) 96 | } 97 | return acc 98 | }, []) 99 | const nextFiles = [...state.files, ...files] 100 | dispatch({ type: 'load', files: nextFiles, fileNames }) 101 | } 102 | } 103 | 104 | const triggerInput = (e) => { 105 | e.persist() 106 | inputRef.current.click() 107 | } 108 | 109 | const getFileUploaderProps = (opts) => ({ 110 | inputRef, 111 | triggerInput, 112 | onChange, 113 | status: state.status, 114 | ...opts, 115 | }) 116 | 117 | // Sets the next file when it detects that its ready to go 118 | useEffect(() => { 119 | if (state.pending.length && state.next == null) { 120 | const next = state.pending[0] 121 | dispatch({ type: 'next', next }) 122 | } 123 | }, [state.next, state.pending]) 124 | 125 | const countRef = useRef(0) 126 | 127 | // Processes the next pending thumbnail when ready 128 | useEffect(() => { 129 | if (state.pending.length && state.next) { 130 | const { next } = state 131 | api 132 | .uploadFile(next) 133 | .then(() => { 134 | const prev = next 135 | logUploadedFile(++countRef.current) 136 | const pending = state.pending.slice(1) 137 | dispatch({ type: 'file-uploaded', prev, pending }) 138 | }) 139 | .catch((error) => { 140 | console.error(error) 141 | dispatch({ type: 'set-upload-error', error }) 142 | }) 143 | } 144 | }, [state]) 145 | 146 | // Ends the upload process 147 | useEffect(() => { 148 | if (!state.pending.length && state.uploading) { 149 | dispatch({ type: 'files-uploaded' }) 150 | } 151 | }, [state.pending.length, state.uploading]) 152 | 153 | return { 154 | ...state, 155 | onSubmit, 156 | onChange, 157 | triggerInput, 158 | getFileUploaderProps, 159 | } 160 | } 161 | 162 | export default useApp 163 | --------------------------------------------------------------------------------