diff --git a/src/App.js b/src/App.js index bdf5914..6f2e71d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,13 +1,13 @@ import React from 'react' import {connect} from 'react-redux' -import {Button} from 'semantic-ui-react' +import {Icon, Button} from 'semantic-ui-react' import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom' import _ from 'lodash' import styles from './App.module.scss' import api from './api' -import {LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage} from './pages' +import {LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage, UploadPage} from './pages' const App = connect((state) => ({login: state.login}))(function App({login}) { // update the API header on each render, the App is rerendered when the login changes @@ -19,44 +19,56 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { return ( -
-
-
OpenBikeSensor
- +
@@ -78,6 +90,11 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { + {login && ( + + + + )} diff --git a/src/App.module.scss b/src/App.module.scss index da0d635..7f5389f 100644 --- a/src/App.module.scss +++ b/src/App.module.scss @@ -1,7 +1,7 @@ @import 'styles.scss'; .App { - text-align: center; + padding-top: 60px; } .header { @@ -11,6 +11,17 @@ align-items: center; } +.headline { + left: 0; + top: 0; + right: 0; + position: fixed; + // border-bottom: 1px solid #E0E0E4; + background: white; + z-index: 100; + box-shadow: 0 0 10px -6px black; +} + .pageTitle { font-family: 'Roboto Slab'; font-weight: 600; diff --git a/src/api.js b/src/api.js index ae38265..d4327fe 100644 --- a/src/api.js +++ b/src/api.js @@ -22,15 +22,19 @@ class API { } async post(url, {body: body_, ...options}) { - const body = typeof body_ !== 'string' ? JSON.stringify(body_) : body_ + let body = body_ + let headers = {...(options.headers || {})} + + if (!(typeof body === 'string' || body instanceof FormData)) { + body = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return await this.fetch(url, { ...options, body, method: 'post', - headers: { - ...(options.headers || {}), - 'Content-Type': 'application/json', - }, + headers }) } diff --git a/src/components/FileDrop.tsx b/src/components/FileDrop.tsx new file mode 100644 index 0000000..340944e --- /dev/null +++ b/src/components/FileDrop.tsx @@ -0,0 +1,206 @@ +// Source: https://github.com/sarink/react-file-drop/blob/master/file-drop/src/FileDrop.tsx +// Original License: MIT +// Adjusted to render prop instead of rendering div directly. + +import PropTypes from 'prop-types' +import React, {DragEvent as ReactDragEvent, DragEventHandler as ReactDragEventHandler, ReactEventHandler} from 'react' + +export type DropEffects = 'copy' | 'move' | 'link' | 'none' + +export interface FileDropProps { + frame?: Exclude | HTMLDocument + onFrameDragEnter?: (event: DragEvent) => void + onFrameDragLeave?: (event: DragEvent) => void + onFrameDrop?: (event: DragEvent) => void + onDragOver?: ReactDragEventHandler + onDragLeave?: ReactDragEventHandler + onDrop?: (files: FileList | null, event: ReactDragEvent) => any + onTargetClick?: ReactEventHandler + dropEffect?: DropEffects + children: (props: ChildrenProps) => React.ReactNode +} + +export interface FileDropState { + draggingOverFrame: boolean + draggingOverTarget: boolean +} + +export interface ChildrenProps extends FileDropState { + onDragOver?: ReactDragEventHandler + onDragLeave: ReactDragEventHandler + onDrop: ReactDragEventHandler + onClick: ReactEventHandler +} + +export default class FileDrop extends React.PureComponent { + static isIE = () => + typeof window !== 'undefined' && + (window.navigator.userAgent.indexOf('MSIE') !== -1 || window.navigator.appVersion.indexOf('Trident/') > 0) + + static eventHasFiles = (event: DragEvent | ReactDragEvent) => { + // In most browsers this is an array, but in IE11 it's an Object :( + let hasFiles = false + if (event.dataTransfer) { + const types = event.dataTransfer.types + for (const keyOrIndex in types) { + if (types[keyOrIndex] === 'Files') { + hasFiles = true + break + } + } + } + return hasFiles + } + + static propTypes = { + onDragOver: PropTypes.func, + onDragLeave: PropTypes.func, + onDrop: PropTypes.func, + onTargetClick: PropTypes.func, + dropEffect: PropTypes.oneOf(['copy', 'move', 'link', 'none']), + frame: (props: FileDropProps, propName: keyof FileDropProps, componentName: string) => { + const prop = props[propName] + if (prop == null) { + return new Error('Warning: Required prop `' + propName + '` was not specified in `' + componentName + '`') + } + if (prop !== document && !(prop instanceof HTMLElement)) { + return new Error('Warning: Prop `' + propName + '` must be one of the following: document, HTMLElement!') + } + }, + onFrameDragEnter: PropTypes.func, + onFrameDragLeave: PropTypes.func, + onFrameDrop: PropTypes.func, + } + + static defaultProps = { + dropEffect: 'copy' as DropEffects, + frame: typeof window === 'undefined' ? undefined : window.document, + } + + constructor(props: FileDropProps) { + super(props) + this.frameDragCounter = 0 + this.state = {draggingOverFrame: false, draggingOverTarget: false} + } + + componentDidMount() { + this.startFrameListeners(this.props.frame) + this.resetDragging() + window.addEventListener('dragover', this.handleWindowDragOverOrDrop) + window.addEventListener('drop', this.handleWindowDragOverOrDrop) + } + + UNSAFE_componentWillReceiveProps(nextProps: FileDropProps) { + if (nextProps.frame !== this.props.frame) { + this.resetDragging() + this.stopFrameListeners(this.props.frame) + this.startFrameListeners(nextProps.frame) + } + } + + componentWillUnmount() { + this.stopFrameListeners(this.props.frame) + window.removeEventListener('dragover', this.handleWindowDragOverOrDrop) + window.removeEventListener('drop', this.handleWindowDragOverOrDrop) + } + + frameDragCounter: number + + resetDragging = () => { + this.frameDragCounter = 0 + this.setState({draggingOverFrame: false, draggingOverTarget: false}) + } + + handleWindowDragOverOrDrop = (event: DragEvent) => { + // This prevents the browser from trying to load whatever file the user dropped on the window + event.preventDefault() + } + + handleFrameDrag = (event: DragEvent) => { + // Only allow dragging of files + if (!FileDrop.eventHasFiles(event)) return + + // We are listening for events on the 'frame', so every time the user drags over any element in the frame's tree, + // the event bubbles up to the frame. By keeping count of how many "dragenters" we get, we can tell if they are still + // "draggingOverFrame" (b/c you get one "dragenter" initially, and one "dragenter"/one "dragleave" for every bubble) + // This is far better than a "dragover" handler, which would be calling `setState` continuously. + this.frameDragCounter += event.type === 'dragenter' ? 1 : -1 + + if (this.frameDragCounter === 1) { + this.setState({draggingOverFrame: true}) + if (this.props.onFrameDragEnter) this.props.onFrameDragEnter(event) + return + } + + if (this.frameDragCounter === 0) { + this.setState({draggingOverFrame: false}) + if (this.props.onFrameDragLeave) this.props.onFrameDragLeave(event) + return + } + } + + handleFrameDrop = (event: DragEvent) => { + if (!this.state.draggingOverTarget) { + this.resetDragging() + if (this.props.onFrameDrop) this.props.onFrameDrop(event) + } + } + + handleDragOver: ReactDragEventHandler = (event) => { + if (FileDrop.eventHasFiles(event)) { + this.setState({draggingOverTarget: true}) + if (!FileDrop.isIE() && this.props.dropEffect) event.dataTransfer.dropEffect = this.props.dropEffect + if (this.props.onDragOver) this.props.onDragOver(event) + } + } + + handleDragLeave: ReactDragEventHandler = (event) => { + this.setState({draggingOverTarget: false}) + if (this.props.onDragLeave) this.props.onDragLeave(event) + } + + handleDrop: ReactDragEventHandler = (event) => { + if (this.props.onDrop && FileDrop.eventHasFiles(event)) { + const files = event.dataTransfer ? event.dataTransfer.files : null + this.props.onDrop(files, event) + } + this.resetDragging() + } + + handleTargetClick: ReactEventHandler = (event) => { + if (this.props.onTargetClick) { + this.props.onTargetClick(event) + } + this.resetDragging() + } + + stopFrameListeners = (frame: FileDropProps['frame']) => { + if (frame) { + frame.removeEventListener('dragenter', this.handleFrameDrag) + frame.removeEventListener('dragleave', this.handleFrameDrag) + frame.removeEventListener('drop', this.handleFrameDrop) + } + } + + startFrameListeners = (frame: FileDropProps['frame']) => { + if (frame) { + frame.addEventListener('dragenter', this.handleFrameDrag) + frame.addEventListener('dragleave', this.handleFrameDrag) + frame.addEventListener('drop', this.handleFrameDrop) + } + } + + render() { + const {children} = this.props + const {draggingOverTarget, draggingOverFrame} = this.state + + return children({ + draggingOverFrame, + draggingOverTarget, + onDragOver: this.handleDragOver, + onDragLeave: this.handleDragLeave, + onDrop: this.handleDrop, + onClick: this.handleTargetClick, + }) + } +} diff --git a/src/components/index.js b/src/components/index.js index 5092c56..1f66023 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,3 +1,4 @@ +export {default as FileDrop} from './FileDrop' export {default as FormattedDate} from './FormattedDate' export {default as LoginForm} from './LoginForm' export {default as Map} from './Map' diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index cb0ed62..cb5b97a 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -20,7 +20,7 @@ function formatDuration(seconds) { function WelcomeMap() { return ( - + ) diff --git a/src/pages/UploadPage.tsx b/src/pages/UploadPage.tsx new file mode 100644 index 0000000..0367053 --- /dev/null +++ b/src/pages/UploadPage.tsx @@ -0,0 +1,236 @@ +import _ from 'lodash' +import React from 'react' +import {List, Loader, Table, Icon, Segment, Header, Button} from 'semantic-ui-react' +import {Link} from 'react-router-dom' + +import {FileDrop, Page} from 'components' +import type {Track} from 'types' +import api from 'api' + +function isSameFile(a: File, b: File) { + return a.name === b.name && a.size === b.size +} + +function formatFileSize(bytes: number) { + if (bytes < 1024) { + return `${bytes} bytes` + } + + bytes /= 1024 + + if (bytes < 1024) { + return `${bytes.toFixed(1)} KiB` + } + + bytes /= 1024 + + if (bytes < 1024) { + return `${bytes.toFixed(1)} MiB` + } + + bytes /= 1024 + return `${bytes.toFixed(1)} GiB` +} + +type FileUploadResult = + | { + track: Track + } + | { + errors: Record + } + +function FileUploadStatus({ + id, + file, + onComplete, +}: { + id: string + file: File + onComplete: (result: FileUploadResult) => void +}) { + const [progress, setProgress] = React.useState(0) + + React.useEffect(() => { + const formData = new FormData() + formData.append('body', file) + + const xhr = new XMLHttpRequest() + + const onProgress = (e) => { + console.log('progress', e) + const progress = (e.loaded || 0) / (e.total || 1) + setProgress(progress) + } + + const onLoad = (e) => { + console.log('loaded', e) + onComplete(id, xhr.response) + } + + xhr.responseType = 'json' + xhr.onload = onLoad + xhr.upload.onprogress = onProgress + xhr.open('POST', '/api/tracks') + xhr.setRequestHeader('Authorization', api.authorization) + xhr.send(formData) + + return () => xhr.abort() + }, [file]) + + return ( + + + {' '} + {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'} + + ) +} + +type FileEntry = { + id: string + file?: File | null + size: number + name: string + result?: FileUploadResult +} + +export default function UploadPage() { + const labelRef = React.useRef() + const [labelRefState, setLabelRefState] = React.useState() + + const [files, setFiles] = React.useState([]) + + const onCompleteFileUpload = React.useCallback( + (id, result) => { + setFiles((files) => files.map((file) => (file.id === id ? {...file, result, file: null} : file))) + }, + [setFiles] + ) + + React.useLayoutEffect(() => { + setLabelRefState(labelRef.current) + }, [labelRef.current]) + + function onSelectFiles(fileList) { + console.log('UPLOAD', fileList) + + const newFiles = Array.from(fileList).map((file) => ({ + id: 'file-' + String(Math.floor(Math.random() * 1000000)), + file, + name: file.name, + size: file.size, + })) + setFiles(files.filter((a) => !newFiles.some((b) => isSameFile(a, b))).concat(newFiles)) + } + + function onChangeField(e) { + if (e.target.files && e.target.files.length) { + onSelectFiles(e.target.files) + } + e.target.value = '' // reset the form field for uploading again + } + + async function onDeleteTrack(slug: string) { + await api.delete(`/tracks/${slug}`) + setFiles((files) => files.filter((t) => t.result?.track?.slug !== slug)) + } + + return ( + + {files.length ? ( + + + + Filename + Size + Status + + + + + + + {files.map(({id, name, size, file, result}) => ( + + + + {name} + + {formatFileSize(size)} + + {result ? ( + <> + Uploaded + + ) : ( + + )} + + + {/*
{JSON.stringify(result || null, null, 2)}
*/} + {result?.errors ? ( + + {_.sortBy(Object.entries(result.errors)) + .filter(([field, message]) => typeof message === 'string') + .map(([field, message]) => ( + + + {message} + + ))} + + ) : null} +
+ + {result?.track ? ( + <> + +
+ ) : null} + + + +
+ ) +} diff --git a/src/pages/index.js b/src/pages/index.js index f21d175..02a8abe 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -4,3 +4,4 @@ export {default as LogoutPage} from './LogoutPage' export {default as NotFoundPage} from './NotFoundPage' export {default as TrackPage} from './TrackPage' export {default as TracksPage} from './TracksPage' +export {default as UploadPage} from './UploadPage'