UI for uploading media attachments (and cancelling them)
Mostly resolves #8, though attachments are still not displayed in public view
This commit is contained in:
		
							parent
							
								
									1efa8e48d1
								
							
						
					
					
						commit
						499beb4484
					
				
							
								
								
									
										
											BIN
										
									
								
								app/assets/images/void.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/assets/images/void.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 180 B | 
| @ -1,12 +1,18 @@ | ||||
| import api from '../api' | ||||
| 
 | ||||
| export const COMPOSE_CHANGE         = 'COMPOSE_CHANGE'; | ||||
| export const COMPOSE_SUBMIT         = 'COMPOSE_SUBMIT'; | ||||
| export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; | ||||
| export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; | ||||
| export const COMPOSE_SUBMIT_FAIL    = 'COMPOSE_SUBMIT_FAIL'; | ||||
| export const COMPOSE_REPLY          = 'COMPOSE_REPLY'; | ||||
| export const COMPOSE_REPLY_CANCEL   = 'COMPOSE_REPLY_CANCEL'; | ||||
| export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE'; | ||||
| export const COMPOSE_SUBMIT          = 'COMPOSE_SUBMIT'; | ||||
| export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST'; | ||||
| export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS'; | ||||
| export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL'; | ||||
| export const COMPOSE_REPLY           = 'COMPOSE_REPLY'; | ||||
| export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL'; | ||||
| export const COMPOSE_UPLOAD          = 'COMPOSE_UPLOAD'; | ||||
| export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST'; | ||||
| export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS'; | ||||
| export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL'; | ||||
| export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; | ||||
| export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO'; | ||||
| 
 | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
| @ -34,7 +40,8 @@ export function submitCompose() { | ||||
| 
 | ||||
|     api(getState).post('/api/statuses', { | ||||
|       status: getState().getIn(['compose', 'text'], ''), | ||||
|       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null) | ||||
|       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), | ||||
|       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')) | ||||
|     }).then(function (response) { | ||||
|       dispatch(submitComposeSuccess(response.data)); | ||||
|     }).catch(function (error) { | ||||
| @ -63,3 +70,56 @@ export function submitComposeFail(error) { | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function uploadCompose(files) { | ||||
|   return function (dispatch, getState) { | ||||
|     dispatch(uploadComposeRequest()); | ||||
| 
 | ||||
|     let data = new FormData(); | ||||
|     data.append('file', files[0]); | ||||
| 
 | ||||
|     api(getState).post('/api/media', data, { | ||||
|       onUploadProgress: function (e) { | ||||
|         dispatch(uploadComposeProgress(e.loaded, e.total)); | ||||
|       } | ||||
|     }).then(function (response) { | ||||
|       dispatch(uploadComposeSuccess(response.data)); | ||||
|     }).catch(function (error) { | ||||
|       dispatch(uploadComposeFail(error)); | ||||
|     }); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function uploadComposeRequest() { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_REQUEST | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function uploadComposeProgress(loaded, total) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_PROGRESS, | ||||
|     loaded: loaded, | ||||
|     total: total | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function uploadComposeSuccess(media) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_SUCCESS, | ||||
|     media: media | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function uploadComposeFail(error) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_FAIL, | ||||
|     error: error | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function undoUploadCompose(media_id) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_UNDO, | ||||
|     media_id: media_id | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -3,9 +3,11 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| const Button = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     text: React.PropTypes.string.isRequired, | ||||
|     text: React.PropTypes.string, | ||||
|     onClick: React.PropTypes.func, | ||||
|     disabled: React.PropTypes.bool | ||||
|     disabled: React.PropTypes.bool, | ||||
|     block: React.PropTypes.bool, | ||||
|     secondary: React.PropTypes.bool | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| @ -18,8 +20,8 @@ const Button = React.createClass({ | ||||
| 
 | ||||
|   render () { | ||||
|     return ( | ||||
|       <button className='button' disabled={this.props.disabled} onClick={this.handleClick} style={{ fontFamily: 'Roboto', display: 'inline-block', position: 'relative', boxSizing: 'border-box', textAlign: 'center', border: '10px none', color: '#fff', fontSize: '14px', fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', padding: '0 16px', height: '36px', cursor: 'pointer', lineHeight: '36px', borderRadius: '4px', textDecoration: 'none' }}> | ||||
|         {this.props.text} | ||||
|       <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ fontFamily: 'Roboto', display: this.props.block ? 'block' : 'inline-block', width: this.props.block ? '100%' : 'auto', position: 'relative', boxSizing: 'border-box', textAlign: 'center', border: '10px none', color: '#fff', fontSize: '14px', fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', padding: '0 16px', height: '36px', cursor: 'pointer', lineHeight: '36px', borderRadius: '4px', textDecoration: 'none' }}> | ||||
|         {this.props.text || this.props.children} | ||||
|       </button> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -3,6 +3,7 @@ import Button             from './button'; | ||||
| import PureRenderMixin    from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ReplyIndicator     from './reply_indicator'; | ||||
| import UploadButton       from './upload_button'; | ||||
| 
 | ||||
| const ComposeForm = React.createClass({ | ||||
| 
 | ||||
| @ -39,7 +40,7 @@ const ComposeForm = React.createClass({ | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ marginBottom: '30px', padding: '10px' }}> | ||||
|       <div style={{ padding: '10px' }}> | ||||
|         {replyArea} | ||||
| 
 | ||||
|         <textarea disabled={this.props.is_submitting} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} /> | ||||
|  | ||||
| @ -2,6 +2,7 @@ import ColumnsArea          from './columns_area'; | ||||
| import Drawer               from './drawer'; | ||||
| import ComposeFormContainer from '../containers/compose_form_container'; | ||||
| import FollowFormContainer  from '../containers/follow_form_container'; | ||||
| import UploadFormContainer  from '../containers/upload_form_container'; | ||||
| import PureRenderMixin      from 'react-addons-pure-render-mixin'; | ||||
| 
 | ||||
| const Frontend = React.createClass({ | ||||
| @ -14,6 +15,7 @@ const Frontend = React.createClass({ | ||||
|         <Drawer> | ||||
|           <div style={{ flex: '1 1 auto' }}> | ||||
|             <ComposeFormContainer /> | ||||
|             <UploadFormContainer /> | ||||
|           </div> | ||||
| 
 | ||||
|           <FollowFormContainer /> | ||||
|  | ||||
| @ -0,0 +1,37 @@ | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import Button          from './button'; | ||||
| 
 | ||||
| const UploadButton = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     disabled: React.PropTypes.bool, | ||||
|     onSelectFile: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   handleChange (e) { | ||||
|     if (e.target.files.length > 0) { | ||||
|       this.props.onSelectFile(e.target.files); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   handleClick () { | ||||
|     this.refs.fileElement.click(); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     return ( | ||||
|       <div> | ||||
|         <Button disabled={this.props.disabled} onClick={this.handleClick} block={true}> | ||||
|           <i className='fa fa-fw fa-photo' /> Add images | ||||
|         </Button> | ||||
| 
 | ||||
|         <input ref='fileElement' type='file' onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default UploadButton; | ||||
							
								
								
									
										41
									
								
								app/assets/javascripts/components/components/upload_form.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								app/assets/javascripts/components/components/upload_form.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | ||||
| import PureRenderMixin    from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import UploadButton       from './upload_button'; | ||||
| import IconButton         from './icon_button'; | ||||
| 
 | ||||
| const UploadForm = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     is_uploading: React.PropTypes.bool, | ||||
|     onSelectFile: React.PropTypes.func.isRequired, | ||||
|     onRemoveFile: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   render () { | ||||
|     let uploads = this.props.media.map(function (attachment) { | ||||
|       return ( | ||||
|         <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'> | ||||
|           <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}> | ||||
|             <IconButton icon='times' title='Undo' size={36} onClick={() => this.props.onRemoveFile(attachment.get('id'))} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       ); | ||||
|     }.bind(this)); | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ marginBottom: '20px', padding: '10px', paddingTop: '0' }}> | ||||
|         <UploadButton onSelectFile={this.props.onSelectFile} disabled={this.props.is_uploading || this.props.media.size > 3} /> | ||||
| 
 | ||||
|         <div style={{ marginTop: '10px', overflow: 'hidden' }}> | ||||
|           {uploads} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default UploadForm; | ||||
| @ -0,0 +1,25 @@ | ||||
| import { connect }                          from 'react-redux'; | ||||
| import UploadForm                           from '../components/upload_form'; | ||||
| import { uploadCompose, undoUploadCompose } from '../actions/compose'; | ||||
| 
 | ||||
| const mapStateToProps = function (state, props) { | ||||
|   return { | ||||
|     media: state.getIn(['compose', 'media_attachments']), | ||||
|     progress: state.getIn(['compose', 'progress']), | ||||
|     is_uploading: state.getIn(['compose', 'is_uploading']) | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = function (dispatch) { | ||||
|   return { | ||||
|     onSelectFile: function (files) { | ||||
|       dispatch(uploadCompose(files)); | ||||
|     }, | ||||
| 
 | ||||
|     onRemoveFile: function (media_id) { | ||||
|       dispatch(undoUploadCompose(media_id)); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); | ||||
| @ -5,7 +5,10 @@ import Immutable           from 'immutable'; | ||||
| const initialState = Immutable.Map({ | ||||
|   text: '', | ||||
|   in_reply_to: null, | ||||
|   is_submitting: false | ||||
|   is_submitting: false, | ||||
|   is_uploading: false, | ||||
|   progress: 0, | ||||
|   media_attachments: Immutable.List([]) | ||||
| }); | ||||
| 
 | ||||
| export default function compose(state = initialState, action) { | ||||
| @ -19,16 +22,33 @@ export default function compose(state = initialState, action) { | ||||
|       }); | ||||
|     case constants.COMPOSE_REPLY_CANCEL: | ||||
|       return state.withMutations(map => { | ||||
|         map.set('in_reply_to', null).set('text', ''); | ||||
|         map.set('in_reply_to', null); | ||||
|         map.set('text', ''); | ||||
|       }); | ||||
|     case constants.COMPOSE_SUBMIT_REQUEST: | ||||
|       return state.set('is_submitting', true); | ||||
|     case constants.COMPOSE_SUBMIT_SUCCESS: | ||||
|       return state.withMutations(map => { | ||||
|         map.set('text', '').set('is_submitting', false).set('in_reply_to', null); | ||||
|         map.set('text', ''); | ||||
|         map.set('is_submitting', false); | ||||
|         map.set('in_reply_to', null); | ||||
|         map.update('media_attachments', list => list.clear()); | ||||
|       }); | ||||
|     case constants.COMPOSE_SUBMIT_FAIL: | ||||
|       return state.set('is_submitting', false); | ||||
|     case constants.COMPOSE_UPLOAD_REQUEST: | ||||
|       return state.set('is_uploading', true); | ||||
|     case constants.COMPOSE_UPLOAD_SUCCESS: | ||||
|       return state.withMutations(map => { | ||||
|         map.update('media_attachments', list => list.push(Immutable.fromJS(action.media))); | ||||
|         map.set('is_uploading', false); | ||||
|       }); | ||||
|     case constants.COMPOSE_UPLOAD_FAIL: | ||||
|       return state.set('is_uploading', false); | ||||
|     case constants.COMPOSE_UPLOAD_UNDO: | ||||
|       return state.update('media_attachments', list => list.filterNot(item => item.get('id') === action.media_id)); | ||||
|     case constants.COMPOSE_UPLOAD_PROGRESS: | ||||
|       return state.set('progress', Math.round((action.loaded / action.total) * 100)); | ||||
|     case TIMELINE_DELETE: | ||||
|       if (action.id === state.get('in_reply_to')) { | ||||
|         return state.set('in_reply_to', null); | ||||
|  | ||||
| @ -8,6 +8,18 @@ | ||||
|   &:disabled { | ||||
|     background-color: #9baec8; | ||||
|   } | ||||
| 
 | ||||
|   &.button-secondary { | ||||
|     background-color: #282c37; | ||||
| 
 | ||||
|     &:hover { | ||||
|       background-color: #282c37; | ||||
|     } | ||||
| 
 | ||||
|     &:disabled { | ||||
|       background-color: #9baec8; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .icon-button { | ||||
| @ -39,7 +51,6 @@ | ||||
| .status__content, .reply-indicator__content { | ||||
|   font-size: 15px; | ||||
|   line-height: 20px; | ||||
|   white-space: pre-wrap; | ||||
|   word-wrap: break-word; | ||||
|   font-weight: 300; | ||||
| 
 | ||||
| @ -95,3 +106,7 @@ | ||||
|     text-decoration: underline; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .transparent-background { | ||||
|   background: image-url('void.png'); | ||||
| } | ||||
|  | ||||
| @ -19,7 +19,7 @@ class PostStatusService < BaseService | ||||
|   def attach_media(status, media_ids) | ||||
|     return if media_ids.nil? || !media_ids.is_a?(Enumerable) | ||||
| 
 | ||||
|     media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(2).map { |id| id.to_i }) | ||||
|     media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map { |id| id.to_i }) | ||||
|     media.update(status_id: status.id) | ||||
|   end | ||||
| 
 | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| object @media | ||||
| attribute :id | ||||
| node(:url) { |media| full_asset_url(media.file.url) } | ||||
| node(:preview_url) { |media| full_asset_url(media.file.url(:small)) } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user