├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── copy_package.js ├── copy_readme.js └── test.js ├── package-lock.json ├── package.json ├── scripts └── publish.sh ├── src ├── AddFileBtn.js ├── DownloadAllBtn.js ├── FileItem.js ├── FileList.js ├── Loading.js ├── index.js ├── styles.js └── utils.js └── webpack.config.js /.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 | .idea 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | dist: dist 5 | sudo: required 6 | script: 7 | - npm ci 8 | - npm run build 9 | cache: 10 | pip: true 11 | directories: 12 | - node_modules 13 | before_deploy: 14 | - if [[ "$TRAVIS_PULL_REQUEST" = false && $TRAVIS_BRANCH == "master" ]]; then 15 | echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc; 16 | fi 17 | deploy: 18 | provider: script 19 | script: bash scripts/publish.sh 20 | skip_cleanup: true 21 | target-branch: $TRAVIS_BRANCH 22 | on: 23 | branches: 24 | only: 25 | - master 26 | # Trigger a push build on master and greenkeeper branches + PRs build on every branches 27 | # Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) 28 | branches: 29 | only: 30 | - master 31 | - /^greenkeeper/.*$/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fusionworks/ra-s3-input 2 | It's an input file for [react-admin](https://github.com/marmelab/react-admin) that uploads files on S3. 3 | This component is an wrapper for [react-s3-uploader](https://github.com/odysseyscience/react-s3-uploader) and uses [react-image-lightbox](https://github.com/frontend-collective/react-image-lightbox) for image galeries. 4 | 5 | ## Install 6 | 7 | `` npm install @fusionworks/ra-s3-input `` 8 | 9 | ## Props 10 | 11 | #### S3FileInput 12 | 13 | |Prop|Type|Description|Default| 14 | |:---:|:---:|:---:|:---:| 15 | |source|**string**| source field in resource object| 16 | |apiRoot|**string**|path to api server | 17 | |fileCoverImg|**string**|source field in your resource model| 18 | |uploadOptions|**object**| options that will be passed to s3Input component (same options as [props for react-s3-uploader](https://github.com/odysseyscience/react-s3-uploader)) | 19 | |multipleFiles|**boolean**|allows to upload multiple files|**false**| 20 | 21 | #### S3FileFolder 22 | 23 | |Prop|Type|Description| 24 | |:---:|:---:|:---:| 25 | |source|**string**| source field in resource object| 26 | |apiRoot|**string**|path to api server | 27 | 28 | 29 | ## Usage 30 | 31 | Using in Create/Edit form is similar : 32 | 33 | ``` jsx 34 | import { Edit, SimpleForm, TextInput } from 'react-admin'; 35 | // import S3FileInput component 36 | import { S3FileInput } from '@fusionworks/ra-s3-input'; 37 | 38 | export const EntityEdit = props => ( 39 | 40 | 41 | 42 | 43 | 54 | 55 | 56 | ); 57 | ``` 58 | And There is a componont for simple showing Files, without ability to add/delete files: 59 | 60 | ```jsx 61 | 62 | import { Show, SimpleShowLayout, TextField } from 'react-admin'; 63 | // import S3FileField 64 | import { S3FileField } from '@fusionworks/ra-s3-input'; 65 | 66 | // use S3FileField component in show View 67 | export const ShowEntity = props => ( 68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | ); 79 | ``` 80 | Also there is required some handling for ```/signIn``` route and for file get reuqests. 81 | 82 | This is haow it is done on a backend server that uses [nestJs](https://github.com/nestjs/nest) : 83 | 84 | ```ts 85 | 86 | // s3.controller 87 | import { Controller, Get, Req, Res, Param } from '@nestjs/common'; 88 | import { S3Service } from './s3.service'; 89 | 90 | @Controller('s3') 91 | export class S3Controller { 92 | constructor(private s3service: S3Service) {} 93 | 94 | @Get('/sign') 95 | sign(@Req() req, @Res() res) { 96 | return this.s3service.signIn(req, res); 97 | } 98 | 99 | @Get('/uploads/yourS3FolderOnBucket/subFolderId/:key') 100 | fileRedirect(@Param('caseId') caseId: string, @Param('key') key: string, @Res() res) { 101 | return this.s3service.tempRedirect(caseId, key, res); 102 | } 103 | } 104 | ``` 105 | 106 | ```ts 107 | // s3.service 108 | import { Injectable } from '@nestjs/common'; 109 | import { InjectConfig, ConfigService } from 'nestjs-config'; 110 | import { S3 } from 'aws-sdk'; 111 | 112 | @Injectable() 113 | export class S3Service { 114 | private readonly s3Bucket: string; 115 | private readonly s3Region: string; 116 | private s3: S3; 117 | 118 | constructor( @InjectConfig() private config: ConfigService ) { 119 | this.s3Bucket = config.get('s3.bucket'); // your s3 bucket name 120 | this.s3Region = config.get('s3.region'); // your s3 Bucket region 121 | this.s3 = new S3({ region: this.s3Region, signatureVersion: 'v4' }); 122 | } 123 | 124 | async signIn(req, res): Promise { 125 | const { objectName, contentType, path } = req.query; 126 | const objectNameChunks = objectName.split('/'); 127 | const filename = objectNameChunks[objectNameChunks.length - 1]; 128 | const mimeType = contentType; 129 | const fileKey = `${path || ''}/${objectName}`; 130 | const params = { 131 | Bucket: this.s3Bucket, 132 | Key: fileKey, 133 | Expires: 60, 134 | ContentType: mimeType, 135 | ACL: 'private', 136 | }; 137 | 138 | res.set({ 'Access-Control-Allow-Origin': '*' }); 139 | this.s3.getSignedUrl('putObject', params, (err, data) => { 140 | if (err) { 141 | return res.status(500, 'Cannot create S3 signed URL'); 142 | } 143 | 144 | res.json({ 145 | signedUrl: data, 146 | publicUrl: '/s3/uploads/' + fileKey, 147 | filename, 148 | fileKey, 149 | }); 150 | }); 151 | } 152 | 153 | tempRedirect(subFolder: string, key: string, res) { 154 | const params = { 155 | Bucket: this.s3Bucket, 156 | Key: `yourS3FolderOnBucket/${subFolder}/${key}`, 157 | }; 158 | this.s3.getSignedUrl('getObject', params, (err, url) => { 159 | res.redirect(url); 160 | }); 161 | } 162 | 163 | } 164 | ``` 165 | 166 | **Note** you need to use aws-sdk module on back-end 167 | -------------------------------------------------------------------------------- /lib/copy_package.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.copyFile('./package.json', './dist/package.json', (err) => { 4 | if (err) { 5 | throw err; 6 | } 7 | console.log('package.json was copied to destination folder.'); 8 | }); -------------------------------------------------------------------------------- /lib/copy_readme.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.copyFile('./README.md', './dist/README.md', (err) => { 4 | if (err) { 5 | throw err; 6 | } 7 | console.log('README.md was copied to destination folder.'); 8 | }); -------------------------------------------------------------------------------- /lib/test.js: -------------------------------------------------------------------------------- 1 | console.log('Nothing to test... yet.'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fusionworks/ra-s3-input", 3 | "version": "0.1.5", 4 | "author": "nzuza", 5 | "private": false, 6 | "main": "index.js", 7 | "homepage": "https://github.com/FusionWorks/react-admin-s3-file-upload#readme", 8 | "description": "React-admin field/input to show markers on a map.", 9 | "license": "ISC", 10 | "scripts": { 11 | "build": "webpack --mode production", 12 | "build:dev": "webpack --mode development --watch", 13 | "postbuild": "node lib/copy_package && node lib/copy_readme", 14 | "patch": "npm version patch -m \"[skip travis] Release version: %s\"", 15 | "prerelease": "npm whoami && npm run patch && npm run build", 16 | "release": "npm publish dist --access public" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/FusionWorks/react-admin-s3-file-upload.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/FusionWorks/react-admin-s3-file-upload/issues" 24 | }, 25 | "keywords": [ 26 | "ra", 27 | "s3", 28 | "react-admin", 29 | "react-admin-input", 30 | "react-admin-s3", 31 | "ra-input", 32 | "react-s3-uploader" 33 | ], 34 | "dependencies": { 35 | "react-s3-uploader": "^4.8.0", 36 | "ra-core": "^2.9.2", 37 | "react": "^16.8.6", 38 | "@material-ui/core": "~1.5.1", 39 | "react-dom": "^16.8.6", 40 | "react-image-lightbox": "^5.1.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "^7.0.0-beta.46", 44 | "@babel/core": "^7.0.0-beta.46", 45 | "@babel/plugin-proposal-class-properties": "^7.0.0-beta.46", 46 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.46", 47 | "@babel/preset-env": "^7.0.0-beta.46", 48 | "@babel/preset-react": "^7.0.0-beta.46", 49 | "babel-loader": "^8.0.0-beta.0", 50 | "css-loader": "^3.0.0", 51 | "file-loader": "^4.0.0", 52 | "react-scripts": "3.0.1", 53 | "style-loader": "^0.23.1", 54 | "webpack": "^4.33.0", 55 | "webpack-cli": "^3.3.2" 56 | }, 57 | "browserslist": { 58 | "production": [ 59 | ">0.2%", 60 | "not dead", 61 | "not op_mini all" 62 | ], 63 | "development": [ 64 | "last 1 chrome version", 65 | "last 1 firefox version", 66 | "last 1 safari version" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PACKAGE_VERSION=$(node -p -e "require('./package.json').version") 3 | REMOTE_VERSION=$(npm show @fusionworks/ra-s3-input version) 4 | LAST_AUTHOR="$(git log -1 --pretty=format:'%an')" 5 | echo "Local version: $PACKAGE_VERSION" 6 | echo "Remote version: $REMOTE_VERSION" 7 | 8 | if [[ $PACKAGE_VERSION != $REMOTE_VERSION ]]; then 9 | echo "Version mismatch. Skip deploy." 10 | elif [[ $LAST_AUTHOR == "Travis CI User" ]]; then 11 | echo "Skip deploy as last commit was made by travis." 12 | elif [[ $TRAVIS_PULL_REQUEST = false && $TRAVIS_BRANCH == "master" ]]; then 13 | git checkout master 14 | git pull origin master 15 | npm run release 16 | # https://github.com/FusionWorks/react-admin-s3-file-upload 17 | git push "https://${GITHUB_TOKEN}@github.com/FusionWorks/react-admin-s3-file-upload.git" master 18 | 19 | git checkout - 20 | else 21 | echo "Thats not master or pull-request to master. Skip deploy." 22 | fi -------------------------------------------------------------------------------- /src/AddFileBtn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactS3Uploader from 'react-s3-uploader'; 3 | import Button from '@material-ui/core/Button'; 4 | 5 | const AddFileBtn = ({ classes, label, uploadOptions }) => ( 6 | <> 7 | 11 | 15 | {label} 16 | 17 | 18 | 19 | > 20 | ); 21 | 22 | export default AddFileBtn; -------------------------------------------------------------------------------- /src/DownloadAllBtn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | 4 | const DownloadAllBtn = ({ serverUrl, folderPath, classes }) => ( 5 | 6 | 10 | Download All 11 | 12 | 13 | ); 14 | 15 | export default DownloadAllBtn; -------------------------------------------------------------------------------- /src/FileItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import { isImage } from './utils'; 4 | 5 | const FileItem = ({ 6 | file, 7 | apiRoot, 8 | showBiggerImg, 9 | classes, 10 | disabled, 11 | deleteFile, 12 | fileCoverImg, 13 | }) => { 14 | const serverUrl = `${apiRoot}/s3/uploads/${file.url}`; 15 | const isImg = isImage(serverUrl); 16 | const backgroundImg = isImg ? serverUrl : fileCoverImg; 17 | 18 | const handleClickImg = () => { 19 | if (isImg) { 20 | showBiggerImg(serverUrl); 21 | } 22 | } 23 | 24 | return ( 25 | <> 26 | 27 | handleClickImg()} 30 | style={{ 'backgroundImage': `url(${backgroundImg})` }}> 31 | 32 | {file.name} 33 | 34 | 35 | 36 | 42 | 43 | Download 44 | 45 | 46 | 47 | {!disabled && 48 | deleteFile(file.name)} 51 | > 52 | Delete 53 | 54 | } 55 | 56 | > 57 | ) 58 | }; 59 | 60 | export default FileItem; -------------------------------------------------------------------------------- /src/FileList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Lightbox from 'react-image-lightbox'; 3 | import FileItem from './FileItem'; 4 | 5 | class FileList extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | gallery: false, 11 | galleryImg: false, 12 | } 13 | } 14 | 15 | showBiggerImg(url) { 16 | this.setState({ 17 | gallery: !this.state.gallery, 18 | galleryImg: url, 19 | }); 20 | } 21 | 22 | render() { 23 | const { files, deleteFile, disabled, classes, apiRoot, fileCoverImg } = this.props; 24 | const { gallery, galleryImg } = this.state; 25 | 26 | return (files.length > 0) ? 27 | <> 28 | 29 | {files.map((file, i) => 30 | 31 | 40 | 41 | )} 42 | 43 | {gallery && ( 44 | this.setState({ gallery: false })} 47 | /> 48 | )} 49 | > : 50 | No Files 51 | } 52 | 53 | } 54 | 55 | export default FileList; -------------------------------------------------------------------------------- /src/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = ({ percentage, classes }) => { 4 | const { loadingWrapper, percent, loading } = classes; 5 | return ( 6 | 7 | {percentage}% 8 | 9 | 10 | ); 11 | } 12 | 13 | export default Loading; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { addField } from 'ra-core'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import styles from './styles'; 5 | import FileList from './FileList'; 6 | import Loading from './Loading'; 7 | import AddFileBtn from './AddFileBtn'; 8 | import DownloadAllBtn from './DownloadAllBtn'; 9 | import 'react-image-lightbox/style.css'; 10 | 11 | class FileUploader extends Component { 12 | constructor(props) { 13 | super(props); 14 | this.uploadInput = React.createRef(); 15 | 16 | this.state = { 17 | fileList: [], 18 | btnLabel: '', 19 | loading: false, 20 | percent: 0 21 | } 22 | } 23 | 24 | componentDidMount() { 25 | this.setState({ 26 | fileList: this.getFilesFromProps(), 27 | }); 28 | } 29 | 30 | getFilesFromProps() { 31 | const { input, record } = this.props; 32 | 33 | if (input) { 34 | return input.value || []; 35 | } 36 | 37 | if (record) { 38 | return record[this.props.source] || []; 39 | } 40 | 41 | return []; 42 | } 43 | 44 | deleteFile(fileName) { 45 | const newFileList = this.state.fileList.filter(file => file.name !== fileName); 46 | this.props.input.onChange(newFileList); 47 | this.setState({ fileList: newFileList }); 48 | } 49 | 50 | getFileList() { return this.state.fileList; } 51 | 52 | onFinishFileUpload(result) { 53 | const newFile = { name: result.filename, url: result.fileKey }; 54 | let newFileList; 55 | 56 | if (this.props.multipleFiles) { 57 | newFileList = [...this.getFileList(), newFile]; 58 | } else { 59 | newFileList = [ newFile ]; 60 | } 61 | 62 | this.setState({ fileList: newFileList }); 63 | this.props.input.onChange(newFileList); 64 | } 65 | 66 | onFileProgress(percentage) { 67 | if (percentage === 0) { 68 | return this.setState({ loading: true }) 69 | } 70 | if (percentage === 100) { 71 | return this.setState({ loading: false }) 72 | } 73 | this.setState({ percent: percentage }) 74 | } 75 | 76 | render() { 77 | const { classes, disabled, uploadOptions, apiRoot, fileCoverImg } = this.props; 78 | const { fileList, loading, percent } = this.state; 79 | 80 | const s3InputOptions = { 81 | signingUrlMethod: "GET", 82 | accept: "*/*", 83 | onFinish: this.onFinishFileUpload.bind(this), 84 | onProgress: this.onFileProgress.bind(this), 85 | uploadRequestHeaders: { 'x-amz-acl': 'public-read' }, 86 | signingUrlWithCredentials: false, 87 | signingUrlQueryParams: { uploadType: 'avatar' }, 88 | autoUpload: true, 89 | className: classes.fileInput, 90 | id: 'filesInput', 91 | ref: this.uploadInput, 92 | ...uploadOptions, 93 | } 94 | 95 | 96 | return ( 97 | 98 | {!disabled && 99 | 104 | } 105 | 113 | {loading && 114 | 115 | } 116 | 117 | ); 118 | } 119 | } 120 | 121 | const StyledFileUploader = withStyles(styles)(FileUploader); 122 | 123 | export const S3FileInput = addField(StyledFileUploader); 124 | export const S3FileField = props => ; -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | const styles = { 2 | wrapper: { 3 | position: 'relative', 4 | paddingBottom: '50px' 5 | }, 6 | fileInput: { 7 | display: 'none' 8 | }, 9 | labelFileInput: { 10 | margin: '0 25px 0 0', 11 | display: 'inline-block', 12 | backgroundColor: '#f5f5f5', 13 | padding: 0 14 | }, 15 | zippedFiles : { 16 | padding: '3px 10px', 17 | display: 'inline-block', 18 | backgroundColor: '#f5f5f5', 19 | }, 20 | addedFiles: { 21 | display: 'flex', 22 | flexDirection: 'column', 23 | width: '100%', 24 | maxWidth: '600px', 25 | alignItems: 'stretch', 26 | listStyle: 'none', 27 | marginLeft: '-40px', 28 | }, 29 | fileItem: { 30 | display: 'flex', 31 | alignItems: 'center', 32 | border: '2px dashed #f5f5f5', 33 | padding: '10px', 34 | borderRadius: '3px', 35 | margin: '10px 0', 36 | position: 'relative' 37 | }, 38 | fileLink: { 39 | display: 'flex', 40 | color: '#888', 41 | justifyContent: 'start', 42 | alignItems: 'center', 43 | textDecoration: 'none', 44 | width: 'calc(100% - 100px)' 45 | }, 46 | fileImg: { 47 | width: '150px', 48 | height: '100px', 49 | backgroundSize: 'auto 100%', 50 | backgroundRepeat: 'no-repeat', 51 | backgroundPosition: 'center', 52 | margin: '0 20px 0 0', 53 | borderRadius: '3px' 54 | }, 55 | fileName: { 56 | maxWidth: 'calc(100% - 250px)', 57 | wordWrap: 'break-word' 58 | }, 59 | rightBlock: { 60 | position: 'absolute', 61 | top: 0, 62 | right: 0, 63 | display: 'inline-flex', 64 | flexDirection: 'column', 65 | justifyContent: 'space-around', 66 | height: '100%', 67 | padding: '10px 10px 10px 0', 68 | boxSizing: 'border-box' 69 | }, 70 | download: { 71 | color: '#2BB656', 72 | fontFamily: 'Exo, sans-serif', 73 | fontWeight: 700, 74 | height: '20px', 75 | }, 76 | delete: { 77 | color: 'rgb(225, 0, 80)', 78 | fontFamily: 'Exo, sans-serif', 79 | fontWeight: 700, 80 | height: '20px', 81 | }, 82 | empty: { 83 | color: '#002D5A', 84 | fontSize: '0.875rem', 85 | fontWeight: 400, 86 | fontFamily: "'Open Sans', 'sans-serif'", 87 | lineHeight: 1, 88 | marginTop: '20px' 89 | }, 90 | loadingWrapper: { 91 | position: 'relative', 92 | maxWidth: '600px', 93 | }, 94 | percent: { 95 | fontSize: '16px', 96 | position:'absolute', 97 | left:0, 98 | right: 0, 99 | textAlign: 'center', 100 | top: '17px' 101 | }, 102 | loading: { 103 | position: 'absolute', 104 | width : '50px', 105 | height: '50px', 106 | border: '2px solid transparent', 107 | borderLeft: '2px solid #84C5E7', 108 | borderRight: '2px solid #84C5E7', 109 | borderRadius: '50%', 110 | animation: 'rotate linear 1s infinite both', 111 | botom:0, 112 | left: '274px', 113 | }, 114 | '@keyframes rotate': { 115 | from: { transform: 'rotate(0deg)' }, 116 | to: { transform: 'rotate(360deg)' } 117 | }, 118 | }; 119 | 120 | export default styles; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isImage = fileName => { 2 | const imgExtensions = ["gif", "png", "jpg", "jpeg", "svg", "bmp"]; 3 | const fileNameChunks = fileName.split('.'); 4 | return fileNameChunks.length > 1 && 5 | imgExtensions 6 | .indexOf(fileNameChunks[fileNameChunks.length - 1].toLowerCase()) !== -1; 7 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: path.join(__dirname, 'src/'), 5 | output: { 6 | path: path.join(__dirname, 'dist'), 7 | filename: 'index.js', 8 | library: '', 9 | libraryTarget: 'commonjs-module' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(js|jsx)$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: 'babel-loader', 18 | options: { 19 | presets: ["@babel/env", "@babel/react"], 20 | plugins: [ 21 | "@babel/plugin-proposal-object-rest-spread", 22 | "@babel/plugin-proposal-class-properties" 23 | ] 24 | } 25 | }, 26 | }, 27 | { 28 | test: /\.css$/i, 29 | use: ['style-loader', 'css-loader'], 30 | }, 31 | { 32 | test: /\.(png|jpg|gif|svg)$/, 33 | loader: 'file-loader', 34 | query: { 35 | name: '[name].[ext]?[hash]' 36 | } 37 | } 38 | ] 39 | }, 40 | resolve: { 41 | extensions: ['.js', '.jsx', '.css'], 42 | }, 43 | } --------------------------------------------------------------------------------