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 |
|
@ -7,6 +7,12 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
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) {
|
export function changeCompose(text) {
|
||||||
return {
|
return {
|
||||||
|
@ -34,7 +40,8 @@ export function submitCompose() {
|
||||||
|
|
||||||
api(getState).post('/api/statuses', {
|
api(getState).post('/api/statuses', {
|
||||||
status: getState().getIn(['compose', 'text'], ''),
|
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) {
|
}).then(function (response) {
|
||||||
dispatch(submitComposeSuccess(response.data));
|
dispatch(submitComposeSuccess(response.data));
|
||||||
}).catch(function (error) {
|
}).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({
|
const Button = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
text: React.PropTypes.string.isRequired,
|
text: React.PropTypes.string,
|
||||||
onClick: React.PropTypes.func,
|
onClick: React.PropTypes.func,
|
||||||
disabled: React.PropTypes.bool
|
disabled: React.PropTypes.bool,
|
||||||
|
block: React.PropTypes.bool,
|
||||||
|
secondary: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -18,8 +20,8 @@ const Button = React.createClass({
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
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' }}>
|
<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.text || this.props.children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Button from './button';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ReplyIndicator from './reply_indicator';
|
import ReplyIndicator from './reply_indicator';
|
||||||
|
import UploadButton from './upload_button';
|
||||||
|
|
||||||
const ComposeForm = React.createClass({
|
const ComposeForm = React.createClass({
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ const ComposeForm = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: '30px', padding: '10px' }}>
|
<div style={{ padding: '10px' }}>
|
||||||
{replyArea}
|
{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' }} />
|
<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 Drawer from './drawer';
|
||||||
import ComposeFormContainer from '../containers/compose_form_container';
|
import ComposeFormContainer from '../containers/compose_form_container';
|
||||||
import FollowFormContainer from '../containers/follow_form_container';
|
import FollowFormContainer from '../containers/follow_form_container';
|
||||||
|
import UploadFormContainer from '../containers/upload_form_container';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
|
||||||
const Frontend = React.createClass({
|
const Frontend = React.createClass({
|
||||||
|
@ -14,6 +15,7 @@ const Frontend = React.createClass({
|
||||||
<Drawer>
|
<Drawer>
|
||||||
<div style={{ flex: '1 1 auto' }}>
|
<div style={{ flex: '1 1 auto' }}>
|
||||||
<ComposeFormContainer />
|
<ComposeFormContainer />
|
||||||
|
<UploadFormContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FollowFormContainer />
|
<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({
|
const initialState = Immutable.Map({
|
||||||
text: '',
|
text: '',
|
||||||
in_reply_to: null,
|
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) {
|
export default function compose(state = initialState, action) {
|
||||||
|
@ -19,16 +22,33 @@ export default function compose(state = initialState, action) {
|
||||||
});
|
});
|
||||||
case constants.COMPOSE_REPLY_CANCEL:
|
case constants.COMPOSE_REPLY_CANCEL:
|
||||||
return state.withMutations(map => {
|
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:
|
case constants.COMPOSE_SUBMIT_REQUEST:
|
||||||
return state.set('is_submitting', true);
|
return state.set('is_submitting', true);
|
||||||
case constants.COMPOSE_SUBMIT_SUCCESS:
|
case constants.COMPOSE_SUBMIT_SUCCESS:
|
||||||
return state.withMutations(map => {
|
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:
|
case constants.COMPOSE_SUBMIT_FAIL:
|
||||||
return state.set('is_submitting', false);
|
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:
|
case TIMELINE_DELETE:
|
||||||
if (action.id === state.get('in_reply_to')) {
|
if (action.id === state.get('in_reply_to')) {
|
||||||
return state.set('in_reply_to', null);
|
return state.set('in_reply_to', null);
|
||||||
|
|
|
@ -8,6 +8,18 @@
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background-color: #9baec8;
|
background-color: #9baec8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.button-secondary {
|
||||||
|
background-color: #282c37;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #282c37;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: #9baec8;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
|
@ -39,7 +51,6 @@
|
||||||
.status__content, .reply-indicator__content {
|
.status__content, .reply-indicator__content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
|
||||||
|
@ -95,3 +106,7 @@
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transparent-background {
|
||||||
|
background: image-url('void.png');
|
||||||
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ class PostStatusService < BaseService
|
||||||
def attach_media(status, media_ids)
|
def attach_media(status, media_ids)
|
||||||
return if media_ids.nil? || !media_ids.is_a?(Enumerable)
|
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)
|
media.update(status_id: status.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
object @media
|
object @media
|
||||||
attribute :id
|
attribute :id
|
||||||
node(:url) { |media| full_asset_url(media.file.url) }
|
node(:url) { |media| full_asset_url(media.file.url) }
|
||||||
|
node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
|
||||||
|
|
Loading…
Reference in a new issue