Skip to content

Commit

Permalink
feat: add upload file component
Browse files Browse the repository at this point in the history
  • Loading branch information
pyphilia committed Mar 19, 2021
1 parent b018180 commit ee03f9f
Show file tree
Hide file tree
Showing 16 changed files with 1,539 additions and 1,328 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
"@material-ui/core": "4.11.2",
"@material-ui/icons": "4.11.2",
"@material-ui/lab": "4.0.0-alpha.57",
"@uppy/core": "1.16.1",
"@uppy/drag-drop": "1.4.25",
"@uppy/react": "1.11.3",
"@uppy/tus": "1.8.5",
"@uppy/xhr-upload": "1.7.0",
"clsx": "1.1.1",
"connected-react-router": "6.8.0",
"dexie": "3.0.3",
Expand Down
26 changes: 18 additions & 8 deletions src/actions/item.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Api from '../api/item';
import { ITEM_TYPES } from '../config/constants';
import {
CREATE_ITEM_SUCCESS,
DELETE_ITEM_SUCCESS,
Expand Down Expand Up @@ -41,17 +42,26 @@ export const setItem = (id) => async (dispatch) => {
dispatch(createFlag(FLAG_SETTING_ITEM, true));
// use saved item when possible
const item = await Api.getItem(id);

const { children, parents } = item;
let newParents = [];
let newChildren = [];
if (!children) {
newChildren = await Api.getChildren(id);
}

let newParents = [];
if (!parents) {
newParents = await buildParentsLine(item.path);
const { children, parents, type } = item;

switch (type) {
case ITEM_TYPES.SPACE: {
if (!children) {
newChildren = await Api.getChildren(id);
}

if (!parents) {
newParents = await buildParentsLine(item.path);
}
break;
}
default:
break;
}

dispatch({
type: SET_ITEM_SUCCESS,
payload: { item, children: newChildren, parents: newParents },
Expand Down
12 changes: 12 additions & 0 deletions src/api/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildCopyItemRoute,
buildDeleteItemRoute,
buildDeleteItemsRoute,
buildDownloadFilesRoute,
buildEditItemRoute,
buildGetChildrenRoute,
buildGetItemRoute,
Expand Down Expand Up @@ -189,3 +190,14 @@ export const getSharedItems = async () => {

return res.json();
};

export const getFileContent = async ({ id }) => {
const response = await fetch(
`${API_HOST}/${buildDownloadFilesRoute(id)}`,
DEFAULT_GET,
);

// Build a URL from the file
const fileURL = URL.createObjectURL(await response.blob());
return fileURL;
};
5 changes: 5 additions & 0 deletions src/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ export const buildGetItemMembershipForItemRoute = (id) =>
export const MEMBERS_ROUTE = `members`;
export const buildGetMemberBy = (email) => `${MEMBERS_ROUTE}?email=${email}`;
export const buildGetMember = (id) => `${MEMBERS_ROUTE}/${id}`;
export const buildUploadFilesRoute = (parentId) =>
parentId
? `${ITEMS_ROUTE}/upload?parentId=${parentId}`
: `${ITEMS_ROUTE}/upload`;
export const buildDownloadFilesRoute = (id) => `${ITEMS_ROUTE}/${id}/download`;
51 changes: 51 additions & 0 deletions src/components/main/FileItem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { Map } from 'immutable';
import PropTypes from 'prop-types';
import { getFileContent } from '../../api/item';
import { MIME_TYPES } from '../../config/constants';

const FileItem = ({ item }) => {
const [url, setUrl] = useState();
const { mimetype } = item.get('extra');
const id = item.get('id');
const name = item.get('name');

useEffect(() => {
(async () => {
const itemUrl = await getFileContent({ id, mimetype });
setUrl(itemUrl);

return () => {
URL.revokeObjectURL(url);
};
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, mimetype, item]);

if (!url) {
return null;
}

if (MIME_TYPES.IMAGE.includes(mimetype)) {
return <img src={url} alt={name} />;
}

if (MIME_TYPES.VIDEO.includes(mimetype)) {
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video controls>
<source src={url} type={mimetype} />
</video>
);
}

// todo: add more file extension

return false;
};

FileItem.propTypes = {
item: PropTypes.instanceOf(Map).isRequired,
};

export default FileItem;
194 changes: 194 additions & 0 deletions src/components/main/FileUploader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React, { Component } from 'react';
import { withStyles } from '@material-ui/core';
import { Map } from 'immutable';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { DragDrop } from '@uppy/react';
import '@uppy/core/dist/style.css';
import '@uppy/drag-drop/dist/style.css';
import { withTranslation } from 'react-i18next';
import { FILE_UPLOAD_MAX_FILES } from '../../config/constants';
import configureUppy from '../../utils/uppy';
import { setItem, getOwnItems } from '../../actions/item';

const styles = (theme) => ({
wrapper: {
display: 'none',
height: '100%',
width: '100%',
boxSizing: 'border-box',
position: 'fixed',
top: 0,
padding: theme.spacing(2),
left: 0,
zIndex: theme.zIndex.drawer + 1,

'& div': {
width: '100%',
},
},
show: {
display: 'flex',
},
invalid: {
'& div button': {
backgroundColor: 'red !important',
},
},
});

class FileUploader extends Component {
static propTypes = {
itemId: PropTypes.instanceOf(Map).isRequired,
dispatchGetOwnItems: PropTypes.func.isRequired,
dispatchSetItem: PropTypes.func.isRequired,
classes: PropTypes.shape({
show: PropTypes.string.isRequired,
invalid: PropTypes.string.isRequired,
wrapper: PropTypes.string.isRequired,
}).isRequired,
t: PropTypes.func.isRequired,
};

state = {
isDragging: false,
isValid: true,
uppy: null,
};

componentDidMount() {
const { itemId } = this.props;
this.setState({
uppy: configureUppy({
itemId,
onComplete: this.onComplete,
}),
});
window.addEventListener('dragenter', this.handleWindowDragEnter);
window.addEventListener('mouseout', this.handleDragEnd);
}

componentDidUpdate({ itemId: prevItemId }) {
const { itemId } = this.props;
if (itemId !== prevItemId) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
uppy: configureUppy({ itemId, onComplete: this.onComplete }),
});
}
}

componentWillUnmount() {
window.removeEventListener('dragenter', this.handleWindowDragEnter);
window.removeEventListener('mouseout', this.handleDragEnd);
}

handleWindowDragEnter = () => {
this.setState({ isDragging: true });
};

handleDragEnd = () => {
const { isDragging } = this.state;
if (isDragging) {
this.setState({ isDragging: false });
}
};

onComplete = (result) => {
const { itemId, dispatchGetOwnItems, dispatchSetItem } = this.props;
// eslint-disable-next-line no-console
console.log('successful files:', result.successful);
// eslint-disable-next-line no-console
console.log('failed files:', result.failed);

// update app on complete
// todo: improve with websockets or by receiving corresponding items
if (!result.failed.length) {
// on Home
if (!itemId) {
return dispatchGetOwnItems();
}
return dispatchSetItem(itemId);
}

return false;
};

handleDragEnter = (event) => {
// detect whether the dragged files number exceeds limit
if (event?.dataTransfer?.items) {
const nbFiles = event.dataTransfer.items.length;

if (nbFiles > FILE_UPLOAD_MAX_FILES) {
return this.setState({ isValid: false });
}
}

return this.setState({ isValid: true });
};

handleDrop = () => {
// todo: trigger error that only MAX_FILES was uploaded
// or cancel drop

this.setState({ isDragging: false });
};

render() {
const { isDragging, isValid, uppy } = this.state;
const { t, classes } = this.props;

if (!uppy) {
return null;
}

return (
<>
<div
className={clsx(classes.wrapper, {
[classes.show]: isDragging,
[classes.invalid]: !isValid,
})}
onDragEnter={(e) => this.handleDragEnter(e)}
onDragEnd={(e) => this.handleDragEnd(e)}
onDragLeave={(e) => this.handleDragEnd(e)}
onDrop={this.handleDrop}
>
<DragDrop
uppy={uppy}
note={t('You can upload up to X files at a time', {
maxFiles: FILE_UPLOAD_MAX_FILES,
})}
locale={{
strings: {
// Text to show on the droppable area.
// `%{browse}` is replaced with a link that opens the system file selection dialog.
dropHereOr: `${t('Drop here or')} %{browse}`,
// Used as the label for the link that opens the system file selection dialog.
browse: t('Browse'),
},
}}
/>
</div>
</>
);
}
}

const mapStateToProps = ({ item }) => ({
itemId: item.getIn(['item', 'id']),
});

const mapDispatchToProps = {
dispatchSetItem: setItem,
dispatchGetOwnItems: getOwnItems,
};

const ConnectedComponent = connect(
mapStateToProps,
mapDispatchToProps,
)(FileUploader);
const StyledComponent = withStyles(styles)(ConnectedComponent);

export default withTranslation()(StyledComponent);
2 changes: 2 additions & 0 deletions src/components/main/Home.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { withRouter } from 'react-router';
import ItemsHeader from './ItemsHeader';
import { setItem, getOwnItems } from '../../actions/item';
import Items from './Items';
import FileUploader from './FileUploader';
import { OWNED_ITEMS_ID } from '../../config/selectors';

class Home extends Component {
Expand Down Expand Up @@ -41,6 +42,7 @@ class Home extends Component {

return (
<>
<FileUploader />
<ItemsHeader />
<Items id={OWNED_ITEMS_ID} title={t('My Items')} items={ownItems} />
</>
Expand Down
20 changes: 11 additions & 9 deletions src/components/main/ItemForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class ItemForm extends Component {
};

render() {
const { open, title, classes, t, confirmText, id } = this.props;
const { open, title, classes, t, confirmText, id, item } = this.props;
const { itemName, itemType, itemDescription, itemImageUrl } = this.state;
return (
<Dialog open={open} onClose={this.onClose} maxWidth="sm" fullWidth>
Expand Down Expand Up @@ -177,14 +177,16 @@ class ItemForm extends Component {
rowsMax={4}
fullWidth
/>
<TextField
id={ITEM_FORM_IMAGE_INPUT_ID}
margin="dense"
label={t('Image (URL)')}
value={itemImageUrl}
onChange={this.handleImageUrlInput}
fullWidth
/>
{ITEM_TYPES.FILE !== item?.type && (
<TextField
id={ITEM_FORM_IMAGE_INPUT_ID}
margin="dense"
label={t('Image (URL)')}
value={itemImageUrl}
onChange={this.handleImageUrlInput}
fullWidth
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={this.onClose} color="primary">
Expand Down
Loading

0 comments on commit ee03f9f

Please sign in to comment.