Multiupload
This commit is contained in:
parent
c6ce5d6415
commit
87f5ecfc56
93
src/App.js
93
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 (
|
||||
<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>
|
||||
|
|
|
@ -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;
|
||||
|
|
14
src/api.js
14
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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
206
src/components/FileDrop.tsx
Normal file
206
src/components/FileDrop.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
236
src/pages/UploadPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue