diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2016-09-07 18:17:15 +0200 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2016-09-07 18:21:57 +0200 |
commit | 499beb4484031703f029511787163e3a5bb6e47a (patch) | |
tree | 415f8990a756fb4c42c0dac3dc434ed68c69ed99 | |
parent | 1efa8e48d1cbf26715a764a9f4acf1b848a9f740 (diff) |
UI for uploading media attachments (and cancelling them)
Mostly resolves #8, though attachments are still not displayed in public view
-rw-r--r-- | app/assets/images/void.png | bin | 0 -> 180 bytes | |||
-rw-r--r-- | app/assets/javascripts/components/actions/compose.jsx | 76 | ||||
-rw-r--r-- | app/assets/javascripts/components/components/button.jsx | 10 | ||||
-rw-r--r-- | app/assets/javascripts/components/components/compose_form.jsx | 3 | ||||
-rw-r--r-- | app/assets/javascripts/components/components/frontend.jsx | 2 | ||||
-rw-r--r-- | app/assets/javascripts/components/components/upload_button.jsx | 37 | ||||
-rw-r--r-- | app/assets/javascripts/components/components/upload_form.jsx | 41 | ||||
-rw-r--r-- | app/assets/javascripts/components/containers/upload_form_container.jsx | 25 | ||||
-rw-r--r-- | app/assets/javascripts/components/reducers/compose.jsx | 26 | ||||
-rw-r--r-- | app/assets/stylesheets/components.scss | 17 | ||||
-rw-r--r-- | app/services/post_status_service.rb | 2 | ||||
-rw-r--r-- | app/views/api/media/create.rabl | 1 |
12 files changed, 222 insertions, 18 deletions
diff --git a/app/assets/images/void.png b/app/assets/images/void.png new file mode 100644 index 000000000..7eeee75af --- /dev/null +++ b/app/assets/images/void.png Binary files differdiff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 4954f0f5c..a107b3352 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -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 + }; +} diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx index 1a55dd879..6f7ace323 100644 --- a/app/assets/javascripts/components/components/button.jsx +++ b/app/assets/javascripts/components/components/button.jsx @@ -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> ); } diff --git a/app/assets/javascripts/components/components/compose_form.jsx b/app/assets/javascripts/components/components/compose_form.jsx index 641412012..40d9b7773 100644 --- a/app/assets/javascripts/components/components/compose_form.jsx +++ b/app/assets/javascripts/components/components/compose_form.jsx @@ -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' }} /> diff --git a/app/assets/javascripts/components/components/frontend.jsx b/app/assets/javascripts/components/components/frontend.jsx index 3532b6674..c227bcc1f 100644 --- a/app/assets/javascripts/components/components/frontend.jsx +++ b/app/assets/javascripts/components/components/frontend.jsx @@ -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 /> diff --git a/app/assets/javascripts/components/components/upload_button.jsx b/app/assets/javascripts/components/components/upload_button.jsx new file mode 100644 index 000000000..295c3b855 --- /dev/null +++ b/app/assets/javascripts/components/components/upload_button.jsx @@ -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; diff --git a/app/assets/javascripts/components/components/upload_form.jsx b/app/assets/javascripts/components/components/upload_form.jsx new file mode 100644 index 000000000..429e672c0 --- /dev/null +++ b/app/assets/javascripts/components/components/upload_form.jsx @@ -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; diff --git a/app/assets/javascripts/components/containers/upload_form_container.jsx b/app/assets/javascripts/components/containers/upload_form_container.jsx new file mode 100644 index 000000000..b168e8187 --- /dev/null +++ b/app/assets/javascripts/components/containers/upload_form_container.jsx @@ -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); diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 5dd093e9a..b4a21b874 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -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); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 39b571183..f1308b11d 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -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'); +} diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index ee2136e3f..930e7ec9e 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -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 diff --git a/app/views/api/media/create.rabl b/app/views/api/media/create.rabl index 23909066d..0239aa585 100644 --- a/app/views/api/media/create.rabl +++ b/app/views/api/media/create.rabl @@ -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)) } |