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
					
				
										
											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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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…
	
		Reference in New Issue