├── .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 |
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 |
--------------------------------------------------------------------------------