Multiupload

This commit is contained in:
Paul Bienkowski 2021-02-17 21:50:18 +01:00
parent c6ce5d6415
commit 87f5ecfc56
8 changed files with 521 additions and 45 deletions

View file

@ -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 (
<Router>
<div>
<header className={styles.header}>
<div className={styles.pageTitle}>OpenBikeSensor</div>
<nav className={styles.menu}>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/feed">Feed</Link>
</li>
<li>
<a href="https://openbikesensor.org/" target="_blank">
About
</a>
</li>
{login ? (
<>
<div className={styles.App}>
<header className={styles.headline}>
<div className={styles.header}>
<div className={styles.pageTitle}>OpenBikeSensor</div>
<nav className={styles.menu}>
<ul>
{login && (
<li>
<Link to="/settings">Settings</Link>
<Link to="/upload">
<Button compact color="green">
<Icon name="cloud upload" />
Upload
</Button>
</Link>
</li>
<li>
<Button as={Link} to="/logout">
Logout
</Button>
</li>
</>
) : (
<>
<li>
<Button as={Link} to="/login">
Login
</Button>
</li>
</>
)}
</ul>
</nav>
)}
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/feed">Feed</Link>
</li>
<li>
<a href="https://openbikesensor.org/" target="_blank">
About
</a>
</li>
{login ? (
<>
<li>
<Link to="/settings">Settings</Link>
</li>
<li>
<Button as={Link} to="/logout" compact>
Logout
</Button>
</li>
</>
) : (
<>
<li>
<Button as={Link} to="/login">
Login
</Button>
</li>
</>
)}
</ul>
</nav>
</div>
</header>
<Switch>
@ -78,6 +90,11 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/logout" exact>
<LogoutPage />
</Route>
{login && (
<Route path="/upload" exact>
<UploadPage />
</Route>
)}
<Route>
<NotFoundPage />
</Route>

View file

@ -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;

View file

@ -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
})
}

206
src/components/FileDrop.tsx Normal file
View file

@ -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<HTMLElementTagNameMap[keyof HTMLElementTagNameMap], HTMLElement> | HTMLDocument
onFrameDragEnter?: (event: DragEvent) => void
onFrameDragLeave?: (event: DragEvent) => void
onFrameDrop?: (event: DragEvent) => void
onDragOver?: ReactDragEventHandler<HTMLDivElement>
onDragLeave?: ReactDragEventHandler<HTMLDivElement>
onDrop?: (files: FileList | null, event: ReactDragEvent<HTMLDivElement>) => any
onTargetClick?: ReactEventHandler<HTMLDivElement>
dropEffect?: DropEffects
children: (props: ChildrenProps) => React.ReactNode
}
export interface FileDropState {
draggingOverFrame: boolean
draggingOverTarget: boolean
}
export interface ChildrenProps extends FileDropState {
onDragOver?: ReactDragEventHandler<HTMLDivElement>
onDragLeave: ReactDragEventHandler<HTMLDivElement>
onDrop: ReactDragEventHandler<HTMLDivElement>
onClick: ReactEventHandler<HTMLDivElement>
}
export default class FileDrop extends React.PureComponent<FileDropProps, FileDropState> {
static isIE = () =>
typeof window !== 'undefined' &&
(window.navigator.userAgent.indexOf('MSIE') !== -1 || window.navigator.appVersion.indexOf('Trident/') > 0)
static eventHasFiles = (event: DragEvent | ReactDragEvent<HTMLElement>) => {
// 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<HTMLDivElement> = (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<HTMLDivElement> = (event) => {
this.setState({draggingOverTarget: false})
if (this.props.onDragLeave) this.props.onDragLeave(event)
}
handleDrop: ReactDragEventHandler<HTMLDivElement> = (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<HTMLDivElement> = (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,
})
}
}

View file

@ -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'

View file

@ -20,7 +20,7 @@ function formatDuration(seconds) {
function WelcomeMap() {
return (
<Map style={{height: '24rem', backgroundColor: '#FEFEF4'}}>
<Map style={{height: '24rem'}}>
<Map.TileLayer />
</Map>
)

236
src/pages/UploadPage.tsx Normal file
View file

@ -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<string, string>
}
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 (
<span>
<Loader inline size="mini" active />
{' '}
{progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
</span>
)
}
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<FileEntry[]>([])
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 (
<Page>
{files.length ? (
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Filename</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map(({id, name, size, file, result}) => (
<Table.Row key={id}>
<Table.Cell>
<Icon name="file" />
{name}
</Table.Cell>
<Table.Cell>{formatFileSize(size)}</Table.Cell>
<Table.Cell>
{result ? (
<>
<Icon name="check" /> Uploaded
</>
) : (
<FileUploadStatus {...{id, file}} onComplete={onCompleteFileUpload} />
)}
</Table.Cell>
<Table.Cell>
{/* <pre>{JSON.stringify(result || null, null, 2)}</pre> */}
{result?.errors ? (
<List>
{_.sortBy(Object.entries(result.errors))
.filter(([field, message]) => typeof message === 'string')
.map(([field, message]) => (
<List.Item key={field}>
<List.Icon name="warning sign" color="red" />
<List.Content>{message}</List.Content>
</List.Item>
))}
</List>
) : null}
</Table.Cell>
<Table.Cell>
{result?.track ? (
<>
<Link to={`/tracks/${result.track.slug}`}>
<Button size="small" icon="arrow right" />
</Link>
<Button size="small" icon="trash" onClick={() => onDeleteTrack(result.track.slug)} />
</>
) : null}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
) : null}
<input
type="file"
id="upload-field"
style={{width: 0, height: 0, position: 'fixed', left: -1000, top: -1000, opacity: 0.001}}
multiple
accept=".csv"
onChange={onChangeField}
/>
<label htmlFor="upload-field" ref={labelRef}>
{labelRefState && (
<FileDrop onDrop={onSelectFiles} frame={labelRefState}>
{({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
<Segment
placeholder
{...{onDragOver, onDragLeave, onDrop}}
style={{
background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
transition: 'background 0.2s',
}}
>
<Header icon>
<Icon name="cloud upload" />
Drop files here or click to select them for upload
</Header>
<Button primary as="span">
Upload files
</Button>
</Segment>
)}
</FileDrop>
)}
</label>
</Page>
)
}

View file

@ -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'