Large update for all components
reorganization, linting, updating file imports, consolidation warning: there will be errors in this commit todo: update webpack, add missing styles, scss files, consolidate the rest of components within features/*
This commit is contained in:
parent
5505f60119
commit
280dc51d85
@ -6,7 +6,7 @@ import {
|
||||
importFetchedAccounts,
|
||||
importErrorWhileFetchingAccountByUsername,
|
||||
} from './importer';
|
||||
import { me } from 'gabsocial/initial_state';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
|
||||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import api from '../api';
|
||||
import { CancelToken, isCancel } from 'axios';
|
||||
import { throttle } from 'lodash';
|
||||
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
|
||||
import { search as emojiSearch } from '../components/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from '../settings';
|
||||
import { useEmoji } from './emojis';
|
||||
import resizeImage from '../utils/resize_image';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import emojify from '../../features/emoji/emoji';
|
||||
import emojify from '../../components/emoji/emoji';
|
||||
import { unescapeHTML } from '../../utils/html';
|
||||
import { expandSpoilers } from '../../initial_state';
|
||||
|
||||
|
@ -22,10 +22,6 @@ export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export const PIN_REQUEST = 'PIN_REQUEST';
|
||||
export const PIN_SUCCESS = 'PIN_SUCCESS';
|
||||
export const PIN_FAIL = 'PIN_FAIL';
|
||||
@ -233,43 +229,6 @@ export function fetchReblogsFail(id, error) {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
if (!me) return;
|
||||
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function pin(status) {
|
||||
return (dispatch, getState) => {
|
||||
if (!me) return;
|
||||
|
@ -2,7 +2,7 @@ import api, { getLinks } from '../api';
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
import { openModal } from './modal';
|
||||
import { me } from 'gabsocial/initial_state';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
|
||||
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import api from '../api';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
import { me } from 'gabsocial/initial_state';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
|
@ -1,8 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
|
||||
exports[`<Button /> adds class "button--secondary" if props.secondary given 1`] = `
|
||||
<button
|
||||
className="button button-secondary"
|
||||
className="button button--secondary"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
|
@ -65,7 +65,7 @@ describe('<Button />', () => {
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('adds class "button-secondary" if props.secondary given', () => {
|
||||
it('adds class "button--secondary" if props.secondary given', () => {
|
||||
const component = renderer.create(<Button secondary />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
|
128
app/javascript/gabsocial/components/account/account.js
Normal file
128
app/javascript/gabsocial/components/account/account.js
Normal file
@ -0,0 +1,128 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Avatar from '../avatar/avatar';
|
||||
import DisplayName from '../display_name';
|
||||
import IconButton from '../icon_button';
|
||||
import { me } from '../../initial_state';
|
||||
|
||||
import './account.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
|
||||
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onMuteNotifications: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
actionIcon: PropTypes.string,
|
||||
actionTitle: PropTypes.string,
|
||||
onActionClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
handleMuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, true);
|
||||
}
|
||||
|
||||
handleUnmuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, false);
|
||||
}
|
||||
|
||||
handleAction = () => {
|
||||
this.props.onActionClick(this.props.account);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { account, intl, hidden, onActionClick, actionIcon, actionTitle } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (onActionClick && actionIcon) {
|
||||
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
|
||||
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
let hidingNotificationsButton;
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
|
||||
} else {
|
||||
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<Fragment>
|
||||
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
|
||||
{hidingNotificationsButton}
|
||||
</Fragment>
|
||||
);
|
||||
} else if (!account.get('moved') || following) {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,128 +1 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Avatar from '../avatar';
|
||||
import DisplayName from '../display_name';
|
||||
import IconButton from '../icon_button';
|
||||
import { me } from '../../initial_state';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
|
||||
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onMuteNotifications: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
actionIcon: PropTypes.string,
|
||||
actionTitle: PropTypes.string,
|
||||
onActionClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
handleMuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, true);
|
||||
}
|
||||
|
||||
handleUnmuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, false);
|
||||
}
|
||||
|
||||
handleAction = () => {
|
||||
this.props.onActionClick(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, intl, hidden, onActionClick, actionIcon, actionTitle } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (onActionClick && actionIcon) {
|
||||
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
|
||||
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
let hidingNotificationsButton;
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
|
||||
} else {
|
||||
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<Fragment>
|
||||
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
|
||||
{hidingNotificationsButton}
|
||||
</Fragment>
|
||||
);
|
||||
} else if (!account.get('moved') || following) {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './account';
|
@ -0,0 +1,35 @@
|
||||
import unicodeMapping from '../emoji/emoji_unicode_mapping_light';
|
||||
|
||||
import './autosuggest_emoji.scss';
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
export default class AutosuggestEmoji extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) return null;
|
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<img className='emojione' src={url} alt={emoji.native || emoji.colons} />
|
||||
{emoji.colons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,35 +1 @@
|
||||
import unicodeMapping from '../../features/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
export default class AutosuggestEmoji extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) return null;
|
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<img className='emojione' src={url} alt={emoji.native || emoji.colons} />
|
||||
{emoji.colons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './autosuggest_emoji';
|
@ -0,0 +1,267 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import AutosuggestAccount from '../../features/compose/components/autosuggest_account';
|
||||
import AutosuggestEmoji from '../autosuggest_emoji';
|
||||
import { isRtl } from '../../utils/rtl';
|
||||
import { textAtCursorMatchesToken } from '../../utils/cursor_token_match';
|
||||
|
||||
import './autosuggest_textbox.scss';
|
||||
|
||||
export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
searchTokens: PropTypes.arrayOf(PropTypes.string),
|
||||
maxLength: PropTypes.number,
|
||||
onPaste: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
textarea: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
searchTokens: ['@', ':', '#'],
|
||||
textarea: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
focused: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore key events during text composition
|
||||
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||
if (e.which === 229 || e.isComposing) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (suggestions.size === 0 || suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) return;
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur();
|
||||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
onPaste = (e) => {
|
||||
if (this.props.onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textbox.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextbox = (c) => {
|
||||
this.textbox = c;
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccount id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
const classes = classNames('autosuggest-textarea__suggestions__item', {
|
||||
selected: i === selectedSuggestion,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classes}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, className, id, maxLength, textarea } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
return [
|
||||
<div className='autosuggest-textarea__wrapper' key='autosuggest-textarea__wrapper'>
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
inputRef={this.setTextbox}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{children}
|
||||
</div>,
|
||||
<div className='autosuggest-textarea__suggestions-wrapper' key='autosuggest-textarea__suggestions-wrapper'>
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setTextbox}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,267 +1 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import AutosuggestAccountContainer from '../../features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestEmoji from '../autosuggest_emoji';
|
||||
import { isRtl } from '../../utils/rtl';
|
||||
import { textAtCursorMatchesToken } from '../../utils/cursor_token_match';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
searchTokens: PropTypes.arrayOf(PropTypes.string),
|
||||
maxLength: PropTypes.number,
|
||||
onPaste: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
textarea: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
searchTokens: ['@', ':', '#'],
|
||||
textarea: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
focused: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore key events during text composition
|
||||
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||
if (e.which === 229 || e.isComposing) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (suggestions.size === 0 || suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) return;
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur();
|
||||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
onPaste = (e) => {
|
||||
if (this.props.onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textbox.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextbox = (c) => {
|
||||
this.textbox = c;
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
const classes = classNames('autosuggest-textarea__suggestions__item', {
|
||||
selected: i === selectedSuggestion,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classes}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, className, id, maxLength, textarea } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
return [
|
||||
<div className='autosuggest-textarea__wrapper' key='autosuggest-textarea__wrapper'>
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
inputRef={this.setTextbox}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{children}
|
||||
</div>,
|
||||
<div className='autosuggest-textarea__suggestions-wrapper' key='autosuggest-textarea__suggestions-wrapper'>
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setTextbox}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './autosuggest_textbox';
|
69
app/javascript/gabsocial/components/avatar/avatar.js
Normal file
69
app/javascript/gabsocial/components/avatar/avatar.js
Normal file
@ -0,0 +1,69 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
|
||||
import './avatar.scss';
|
||||
|
||||
export default class Avatar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
size: PropTypes.number,
|
||||
inline: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
account: ImmutableMap(),
|
||||
animate: autoPlayGif,
|
||||
inline: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: false,
|
||||
sameImg: this.props.account.get('avatar') === this.props.account.get('avatar_static'),
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
if (this.props.animate || this.state.sameImg) return;
|
||||
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.animate || this.state.sameImg) return;
|
||||
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, size, animate, inline } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
// : TODO : remove inline and change all avatars to be sized using css
|
||||
const style = !size ? {} : {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
};
|
||||
|
||||
const theSrc = account.get((hovering || animate) ? 'avatar' : 'avatar_static');
|
||||
|
||||
const className = classNames('account__avatar', {
|
||||
'account__avatar--inline': inline,
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
style={style}
|
||||
src={theSrc}
|
||||
alt={account.get('display_name')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,69 +1 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class Avatar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
size: PropTypes.number,
|
||||
inline: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
account: ImmutableMap(),
|
||||
animate: autoPlayGif,
|
||||
inline: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: false,
|
||||
sameImg: this.props.account.get('avatar') === this.props.account.get('avatar_static'),
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
if (this.props.animate || this.state.sameImg) return;
|
||||
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.animate || this.state.sameImg) return;
|
||||
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, size, animate, inline } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
// : TODO : remove inline and change all avatars to be sized using css
|
||||
const style = !size ? {} : {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
};
|
||||
|
||||
const theSrc = account.get((hovering || animate) ? 'avatar' : 'avatar_static');
|
||||
|
||||
const className = classNames('account__avatar', {
|
||||
'account__avatar--inline': inline,
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
style={style}
|
||||
src={theSrc}
|
||||
alt={account.get('display_name')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './avatar';
|
@ -0,0 +1,33 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
|
||||
import './avatar_overlay.scss';
|
||||
|
||||
export default class AvatarOverlay extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
friend: ImmutablePropTypes.map.isRequired,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: autoPlayGif,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, friend, animate } = this.props;
|
||||
|
||||
const baseSrc = account.get(animate ? 'avatar' : 'avatar_static');
|
||||
const overlaySrc = friend.get(animate ? 'avatar' : 'avatar_static');
|
||||
|
||||
return (
|
||||
<div className='avatar-overlay'>
|
||||
<img className='avatar-overlay__base' src={baseSrc} />
|
||||
<img className='avatar-overlay__overlay' src={overlaySrc} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,33 +1 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class AvatarOverlay extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
friend: ImmutablePropTypes.map.isRequired,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: autoPlayGif,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, friend, animate } = this.props;
|
||||
|
||||
const baseSrc = account.get(animate ? 'avatar' : 'avatar_static');
|
||||
const overlaySrc = friend.get(animate ? 'avatar' : 'avatar_static');
|
||||
|
||||
return (
|
||||
<div className='avatar-overlay'>
|
||||
<img className='avatar-overlay__base' src={baseSrc} />
|
||||
<img className='avatar-overlay__overlay' src={overlaySrc} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './avatar_overlay';
|
25
app/javascript/gabsocial/components/badge/badge.js
Normal file
25
app/javascript/gabsocial/components/badge/badge.js
Normal file
@ -0,0 +1,25 @@
|
||||
import './badge.scss';
|
||||
|
||||
export default class Badge extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
type: PropTypes.oneOf([
|
||||
'pro',
|
||||
'donor',
|
||||
'investor',
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type } = this.props;
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
return (
|
||||
<span className={`badge badge--${type}`}>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
@ -1,25 +1 @@
|
||||
import './index.scss';
|
||||
|
||||
export default class Badge extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
type: PropTypes.oneOf([
|
||||
'pro',
|
||||
'donor',
|
||||
'investor',
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type } = this.props;
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
return (
|
||||
<span className={`badge badge--${type}`}>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
export { default } from './badge';
|
@ -1,8 +1,9 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Column from '../column';
|
||||
import ColumnHeader from '../column_header';
|
||||
import IconButton from '../icon_button';
|
||||
|
||||
import Column from './column';
|
||||
import ColumnHeader from './column_header';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import './bundle_column_error.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
|
||||
@ -10,6 +11,7 @@ const messages = defineMessages({
|
||||
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class BundleColumnError extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@ -36,5 +38,3 @@ class BundleColumnError extends PureComponent {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleColumnError);
|
@ -0,0 +1,29 @@
|
||||
.error-column {
|
||||
color: $dark-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 40px;
|
||||
cursor: default;
|
||||
flex: 1 1 auto;
|
||||
min-height: 160px;
|
||||
|
||||
@include flex(center, center, column);
|
||||
@include text-sizing(15px, 400, 1, center);
|
||||
|
||||
@supports(display: grid) {
|
||||
// hack to fix Chrome <57
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
&>span {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $highlight-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './bundle_column_error';
|
@ -1,6 +1,7 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from '../icon_button';
|
||||
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import './bundle_modal_error.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
|
||||
@ -8,6 +9,7 @@ const messages = defineMessages({
|
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class BundleModalError extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@ -48,4 +50,3 @@ class BundleModalError extends PureComponent {
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleModalError);
|
@ -0,0 +1 @@
|
||||
export { default } from './bundle_modal_error';
|
49
app/javascript/gabsocial/components/button/button.js
Normal file
49
app/javascript/gabsocial/components/button/button.js
Normal file
@ -0,0 +1,49 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import './button.scss';
|
||||
|
||||
export default class Button extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled && this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render () {
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button--secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,47 +1 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Button extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled && this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render () {
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button--secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './button';
|
@ -1,7 +1,8 @@
|
||||
import ColumnHeader from './column_header';
|
||||
import { isMobile } from '../../../utils/is_mobile';
|
||||
import ColumnBackButton from '../../../components/column_back_button';
|
||||
import ColumnBackButtonSlim from '../../../components/column_back_button_slim';
|
||||
import ColumnHeader from '../column_header';
|
||||
import { isMobile } from '../../utils/is_mobile';
|
||||
import ColumnBackButton from '../column_back_button';
|
||||
|
||||
import './column.scss';
|
||||
|
||||
export default class Column extends PureComponent {
|
||||
|
||||
@ -11,25 +12,24 @@ export default class Column extends PureComponent {
|
||||
children: PropTypes.node,
|
||||
active: PropTypes.bool,
|
||||
hideHeadingOnMobile: PropTypes.bool,
|
||||
backBtnSlim: PropTypes.bool,
|
||||
backBtn: PropTypes.oneOf([
|
||||
'normal',
|
||||
'slim',
|
||||
]),
|
||||
};
|
||||
|
||||
render () {
|
||||
const { heading, icon, children, active, hideHeadingOnMobile, backBtnSlim } = this.props;
|
||||
const { heading, icon, children, active, hideHeadingOnMobile, backBtn } = this.props;
|
||||
|
||||
const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
|
||||
|
||||
const columnHeaderId = showHeading && heading.replace(/ /g, '-');
|
||||
const header = showHeading && (
|
||||
<ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />
|
||||
);
|
||||
|
||||
const backBtn = backBtnSlim ? (<ColumnBackButtonSlim/>) : (<ColumnBackButton/>);
|
||||
// const header = showHeading && (
|
||||
// <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />
|
||||
// );
|
||||
|
||||
return (
|
||||
<div role='region' aria-labelledby={columnHeaderId} className='column'>
|
||||
{header}
|
||||
{backBtn}
|
||||
{ backBtn && <ColumnBackButton slim={backBtn === 'slim'} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
@ -1,20 +1 @@
|
||||
import './index.scss';
|
||||
|
||||
export default class Column extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
label: PropTypes.string,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { label, children } = this.props;
|
||||
|
||||
return (
|
||||
<div role='region' aria-label={label} className='column'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './column';
|
@ -0,0 +1,40 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './column_back_button.scss';
|
||||
|
||||
export default class ColumnBackButton extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
slim: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/home'); // homehack
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { slim } = this.props;
|
||||
|
||||
const btnClasses = classNames('column-back-button', {
|
||||
'column-back-button--slim': slim,
|
||||
});
|
||||
|
||||
return (
|
||||
<button className={btnClasses} onClick={this.handleClick}>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,40 +1 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class ColumnBackButton extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
slim: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/home'); // homehack
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { slim } = this.props;
|
||||
|
||||
const btnClasses = classNames('column-back-button', {
|
||||
'column-back-button--slim': slim,
|
||||
});
|
||||
|
||||
return (
|
||||
<button className={btnClasses} onClick={this.handleClick}>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './column_back_button';
|
@ -0,0 +1,125 @@
|
||||
import { Fragment } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './column_header.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class ColumnHeader extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
title: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
};
|
||||
|
||||
historyBack = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/home'); // homehack
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({
|
||||
collapsed: !this.state.collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
this.historyBack();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, icon, active, children, intl: { formatMessage } } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
'column-header__wrapper--active': active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
'column-header--active': active,
|
||||
});
|
||||
|
||||
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
|
||||
const hasTitle = icon && title;
|
||||
const hasChildren = !!children;
|
||||
|
||||
if (!hasChildren && !hasTitle) {
|
||||
return null;
|
||||
} else if (!hasChildren && hasTitle) {
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
'column-header__collapsible--collapsed': collapsed,
|
||||
});
|
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
'column-header__button--active': !collapsed,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
{
|
||||
hasTitle && (
|
||||
<Fragment>
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
className={collapsibleButtonClassName}
|
||||
title={btnTitle}
|
||||
aria-label={btnTitle}
|
||||
aria-pressed={!collapsed}
|
||||
onClick={this.handleToggleClick}
|
||||
>
|
||||
<Icon id='sliders' />
|
||||
</button>
|
||||
</h1>
|
||||
|
||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
|
||||
<div className='column-header__collapsible-inner'>
|
||||
{
|
||||
!collapsed &&
|
||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -35,7 +35,6 @@
|
||||
|
||||
&--active {
|
||||
&::before {
|
||||
margin: 0 auto;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
|
||||
@ -43,6 +42,7 @@
|
||||
@include pseudo;
|
||||
@include size(60%, 28px);
|
||||
@include abs-position(35px, 0, auto, 0, false);
|
||||
@include margin-center;
|
||||
}
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ import { createSelector } from 'reselect';
|
||||
import Icon from '../icon';
|
||||
import { fetchLists } from '../../actions/lists';
|
||||
|
||||
import './index.scss';
|
||||
import './column_header.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
@ -31,7 +31,9 @@ const mapStateToProps = state => {
|
||||
};
|
||||
};
|
||||
|
||||
class ColumnHeader extends ImmutablePureComponent {
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class HomeColumnHeader extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
@ -166,5 +168,3 @@ class ColumnHeader extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(connect(mapStateToProps)(ColumnHeader));
|
@ -1,125 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class ColumnHeader extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
title: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
};
|
||||
|
||||
historyBack = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/home'); // homehack
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({
|
||||
collapsed: !this.state.collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
this.historyBack();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, icon, active, children, intl: { formatMessage } } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
'column-header__wrapper--active': active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
'column-header--active': active,
|
||||
});
|
||||
|
||||
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
|
||||
const hasTitle = icon && title;
|
||||
const hasChildren = !!children;
|
||||
|
||||
if (!hasChildren && !hasTitle) {
|
||||
return null;
|
||||
} else if (!hasChildren && hasTitle) {
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
'column-header__collapsible--collapsed': collapsed,
|
||||
});
|
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
'column-header__button--active': !collapsed,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
{
|
||||
hasTitle && (
|
||||
<Fragment>
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
className={collapsibleButtonClassName}
|
||||
title={btnTitle}
|
||||
aria-label={btnTitle}
|
||||
aria-pressed={!collapsed}
|
||||
onClick={this.handleToggleClick}
|
||||
>
|
||||
<Icon id='sliders' />
|
||||
</button>
|
||||
</h1>
|
||||
|
||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
|
||||
<div className='column-header__collapsible-inner'>
|
||||
{
|
||||
!collapsed &&
|
||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import ColumnHeader from './column_header';
|
||||
import HomeColumnHeader from './home_column_header';
|
||||
|
||||
export {
|
||||
ColumnHeader,
|
||||
HomeColumnHeader,
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Column from '../column';
|
||||
|
||||
import './column_indicator.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading...' },
|
||||
missing: { id: 'missing_indicator.sublabel', defaultMessage: 'This resource could not be found.' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class ColumnIndicator extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
type: PropTypes.oneOf([
|
||||
'loading',
|
||||
'missing',
|
||||
'error',
|
||||
]),
|
||||
message: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
]),
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, message, intl } = this.props;
|
||||
|
||||
const title = type !== 'error' ? intl.formatMessage(messages[type]) : message;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<div className={`column-indicator column-indicator--${type}`}>
|
||||
<div className='column-indicator__figure' />
|
||||
<span className='column-indicator__title'>{title}</span>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
@ -1,41 +1 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Column from '../column';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading...' },
|
||||
missing: { id: 'missing_indicator.sublabel', defaultMessage: 'This resource could not be found.' },
|
||||
});
|
||||
export default @injectIntl
|
||||
class ColumnIndicator extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
type: PropTypes.oneOf([
|
||||
'loading',
|
||||
'missing',
|
||||
'error',
|
||||
]),
|
||||
message: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
]),
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, message, intl } = this.props;
|
||||
|
||||
const title = type !== 'error' ? intl.formatMessage(messages[type]) : message;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<div className={`column-indicator column-indicator--${type}`}>
|
||||
<div className='column-indicator__figure' />
|
||||
<span className='column-indicator__title'>{title}</span>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
export { default } from './column_indicator';
|
@ -0,0 +1,22 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Icon from '../../components/icon';
|
||||
|
||||
export default class ColumnLink extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
to: PropTypes.string,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { to, icon, text } = this.props;
|
||||
|
||||
return (
|
||||
<Link to={to} className='column-link'>
|
||||
<Icon id={icon} fixedWidth className='column-link__icon' />
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
@ -0,0 +1,39 @@
|
||||
.column-link {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
color: $primary-text-color;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: lighten($ui-base-color, 11%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&--transparent {
|
||||
background: transparent;
|
||||
color: $ui-secondary-color;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $ui-highlight-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
1
app/javascript/gabsocial/components/column_link/index.js
Normal file
1
app/javascript/gabsocial/components/column_link/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './column_link';
|
@ -0,0 +1,17 @@
|
||||
import './column_subheading.scss';
|
||||
|
||||
export default class ColumnSubheading extends PureComponent {
|
||||
static propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { text } = this.props;
|
||||
|
||||
return (
|
||||
<div className='column-subheading'>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
.column-subheading {
|
||||
background: $ui-base-color;
|
||||
color: $dark-text-color;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './column_subheading';
|
@ -0,0 +1,42 @@
|
||||
import './columns_area.scss';
|
||||
|
||||
export default class ColumnsArea extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
layout: PropTypes.object,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
const layout = this.props.layout || {LEFT:null,RIGHT:null};
|
||||
|
||||
return (
|
||||
<div className='page'>
|
||||
<div className='page__columns'>
|
||||
<div className='columns-area__panels'>
|
||||
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
|
||||
<div className='columns-area__panels__pane__inner'>
|
||||
{layout.LEFT}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='columns-area__panels__main'>
|
||||
<div className='columns-area columns-area--mobile'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
|
||||
<div className='columns-area__panels__pane__inner'>
|
||||
{layout.RIGHT}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,187 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
z-index: 105;
|
||||
|
||||
@include size(100%, auto);
|
||||
|
||||
@media (min-width:895px) {
|
||||
top: -290px;
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include size(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.columns-area {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
|
||||
&__panels {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@include size(100%);
|
||||
|
||||
&__pane {
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 15px;
|
||||
|
||||
&--start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__inner {
|
||||
pointer-events: auto;
|
||||
|
||||
@include size(265px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 0 20px;
|
||||
|
||||
@media screen and (max-width: 375px) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 895px) {
|
||||
margin: 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media screen and (min-width: 631px) {
|
||||
.columns-area {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.columns-area>div {
|
||||
|
||||
.column,
|
||||
.drawer {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.columns-area--mobile {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
@include margin-center;
|
||||
|
||||
.column,
|
||||
.drawer {
|
||||
padding: 0;
|
||||
|
||||
@include size(100%);
|
||||
}
|
||||
|
||||
.search__input {
|
||||
line-height: 18px;
|
||||
font-size: 16px;
|
||||
padding: 15px 30px 15px 15px;
|
||||
}
|
||||
|
||||
.search__icon .fa {
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 360px) {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 630px) {
|
||||
.detailed-status {
|
||||
padding: 15px;
|
||||
|
||||
.media-gallery,
|
||||
.video-player {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.account__header__bar {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.navigation-bar,
|
||||
.compose-form {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.compose-form .compose-form__publish .compose-form__publish-button-wrapper {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.account {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.notification {
|
||||
&__message {
|
||||
margin-left: 48px + 15px * 2;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
&__favourite-icon-wrapper {
|
||||
left: -32px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.account {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.account__avatar-wrapper {
|
||||
margin-left: 17px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 895px) {
|
||||
.columns-area__panels__pane--left {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1180px) {
|
||||
.columns-area__panels__pane--right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.columns-area--mobile .column {
|
||||
// @include gab-container-standards();
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './columns_area';
|
@ -0,0 +1,27 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import VerifiedIcon from '../verified_icon/verified_icon';
|
||||
|
||||
import './display_name.scss';
|
||||
|
||||
export default class DisplayName extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
|
||||
return (
|
||||
<span className='display-name'>
|
||||
<bdi>
|
||||
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||
</bdi>
|
||||
{account.get('is_verified') && <VerifiedIcon />}
|
||||
<span className='display-name__account'>@{account.get('acct')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,27 +1 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import VerifiedIcon from '../verified_icon';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class DisplayName extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
|
||||
return (
|
||||
<span className='display-name'>
|
||||
<bdi>
|
||||
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||
</bdi>
|
||||
{account.get('is_verified') && <VerifiedIcon />}
|
||||
<span className='display-name__account'>@{account.get('acct')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './display_name';
|
48
app/javascript/gabsocial/components/domain/domain.js
Normal file
48
app/javascript/gabsocial/components/domain/domain.js
Normal file
@ -0,0 +1,48 @@
|
||||
import IconButton from '../icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import './domain.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Domain extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
domain: PropTypes.string,
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleDomainUnblock = () => {
|
||||
this.props.onUnblockDomain(this.props.domain);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { domain, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='domain'>
|
||||
<div className='domain__wrapper'>
|
||||
<span className='domain__name'>
|
||||
<strong>{domain}</strong>
|
||||
</span>
|
||||
|
||||
<div className='domain__buttons'>
|
||||
<IconButton
|
||||
active
|
||||
icon='unlock'
|
||||
title={intl.formatMessage(messages.unblockDomain, {
|
||||
domain,
|
||||
})}
|
||||
onClick={this.handleDomainUnblock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,48 +1 @@
|
||||
import IconButton from '../icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Domain extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
domain: PropTypes.string,
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleDomainUnblock = () => {
|
||||
this.props.onUnblockDomain(this.props.domain);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { domain, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='domain'>
|
||||
<div className='domain__wrapper'>
|
||||
<span className='domain__name'>
|
||||
<strong>{domain}</strong>
|
||||
</span>
|
||||
|
||||
<div className='domain__buttons'>
|
||||
<IconButton
|
||||
active
|
||||
icon='unlock'
|
||||
title={intl.formatMessage(messages.unblockDomain, {
|
||||
domain,
|
||||
})}
|
||||
onClick={this.handleDomainUnblock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './domain';
|
@ -0,0 +1,272 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import IconButton from '../icon_button';
|
||||
import Motion from '../../features/ui/util/optional_motion';
|
||||
|
||||
import './dropdown_menu.scss';
|
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
class DropdownMenu extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
|
||||
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('keydown', this.handleKeyDown, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement);
|
||||
let element;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index+1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index-1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length-1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem (option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href = '#', newTab, isLogout } = option;
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleItemKeyDown}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : null}
|
||||
data-method={isLogout ? 'delete' : null}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class Dropdown extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
title: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
dropdownPlacement: PropTypes.string,
|
||||
openDropdownId: PropTypes.number,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
};
|
||||
|
||||
handleClick = ({ target, type }) => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
} else {
|
||||
const { top } = target.getBoundingClientRect();
|
||||
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.props.onClose(this.state.id);
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<div onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
ref={this.setTargetRef}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,272 +1 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import IconButton from '../icon_button';
|
||||
import Motion from '../../features/ui/util/optional_motion';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
class DropdownMenu extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
|
||||
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('keydown', this.handleKeyDown, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement);
|
||||
let element;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index+1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index-1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length-1];
|
||||
if (element) element.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem (option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href = '#', newTab, isLogout } = option;
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleItemKeyDown}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : null}
|
||||
data-method={isLogout ? 'delete' : null}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class Dropdown extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
title: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
dropdownPlacement: PropTypes.string,
|
||||
openDropdownId: PropTypes.number,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
};
|
||||
|
||||
handleClick = ({ target, type }) => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
} else {
|
||||
const { top } = target.getBoundingClientRect();
|
||||
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.props.onClose(this.state.id);
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<div onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
ref={this.setTargetRef}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './dropdown_menu';
|
@ -0,0 +1,40 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import './error_boundary.scss';
|
||||
|
||||
export default class ErrorBoundary extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
hasError: false,
|
||||
stackTrace: undefined,
|
||||
componentStack: undefined,
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
stackTrace: error.stack,
|
||||
componentStack: info && info.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError } = this.state;
|
||||
|
||||
if (!hasError) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className='error-boundary'>
|
||||
<div className='error-boundary__container'>
|
||||
<FormattedMessage id='alert.unexpected.message' defaultMessage='Error' />
|
||||
<a className='error-boundary__link' href='/home'>Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
.error-boundary {
|
||||
&__container {
|
||||
margin: auto;
|
||||
@include margin-center(auto);
|
||||
}
|
||||
|
||||
span {
|
||||
@ -11,8 +11,9 @@
|
||||
|
||||
&__link {
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
text-align: center;
|
||||
color: $gab-brand-default;
|
||||
|
||||
@include margin-center(15px);
|
||||
}
|
||||
}
|
@ -1,40 +1 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class ErrorBoundary extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
hasError: false,
|
||||
stackTrace: undefined,
|
||||
componentStack: undefined,
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
stackTrace: error.stack,
|
||||
componentStack: info && info.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError } = this.state;
|
||||
|
||||
if (!hasError) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className='error-boundary'>
|
||||
<div className='error-boundary__container'>
|
||||
<FormattedMessage id='alert.unexpected.message' defaultMessage='Error' />
|
||||
<a className='error-boundary__link' href='/home'>Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './error_boundary';
|
@ -0,0 +1,62 @@
|
||||
import './extended_video_player.scss';
|
||||
|
||||
export default class ExtendedVideoPlayer extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
time: PropTypes.number,
|
||||
controls: PropTypes.bool.isRequired,
|
||||
muted: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleLoadedData = () => {
|
||||
if (this.props.time) {
|
||||
this.video.currentTime = this.props.time;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.video = c;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
e.stopPropagation();
|
||||
const handler = this.props.onClick;
|
||||
if (handler) handler();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { src, muted, controls, alt } = this.props;
|
||||
|
||||
return (
|
||||
<div className='extended-video-player'>
|
||||
<video
|
||||
ref={this.setRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
muted={muted}
|
||||
controls={controls}
|
||||
loop={!controls}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,62 +1 @@
|
||||
import './index.scss';
|
||||
|
||||
export default class ExtendedVideoPlayer extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
time: PropTypes.number,
|
||||
controls: PropTypes.bool.isRequired,
|
||||
muted: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleLoadedData = () => {
|
||||
if (this.props.time) {
|
||||
this.video.currentTime = this.props.time;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.video = c;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
e.stopPropagation();
|
||||
const handler = this.props.onClick;
|
||||
if (handler) handler();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { src, muted, controls, alt } = this.props;
|
||||
|
||||
return (
|
||||
<div className='extended-video-player'>
|
||||
<video
|
||||
ref={this.setRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
muted={muted}
|
||||
controls={controls}
|
||||
loop={!controls}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './extended_video_player';
|
@ -0,0 +1,24 @@
|
||||
import './floating_action_button.scss';
|
||||
|
||||
export default class FloatingActionButton extends Component {
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (nextProps.message !== this.props.message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onClick, message } = this.props;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className='floating-action-button' aria-label={message} />
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
.floating-action-button {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
|
||||
@include size(61px, 52px);
|
||||
@include abs-position(auto, 14px, 14px, auto, false);
|
||||
@include background-image('/assets/images/sprite-main-navigation.png', 161px 152px, -100px 0);
|
||||
|
||||
@media screen and (max-width: $nav-breakpoint-3) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-position: -100px -100px;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './floating_action_button';
|
23
app/javascript/gabsocial/components/icon/icon.js
Normal file
23
app/javascript/gabsocial/components/icon/icon.js
Normal file
@ -0,0 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Icon extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { id, className, fixedWidth, ...other } = this.props;
|
||||
|
||||
const classes = classNames('fa', `fa-${id}`, className, {
|
||||
'fa-fw': fixedWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<i role='img' alt={id} className={classes} {...other} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,23 +1 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Icon extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { id, className, fixedWidth, ...other } = this.props;
|
||||
|
||||
const classes = classNames('fa', `fa-${id}`, className, {
|
||||
'fa-fw': fixedWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<i role='img' alt={id} className={classes} {...other} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './icon';
|
114
app/javascript/gabsocial/components/icon_button/icon_button.js
Normal file
114
app/javascript/gabsocial/components/icon_button/icon_button.js
Normal file
@ -0,0 +1,114 @@
|
||||
import classNames from 'classnames';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import Motion from '../../features/ui/util/optional_motion';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './icon_button.scss';
|
||||
|
||||
export default class IconButton extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
pressed: PropTypes.bool,
|
||||
expanded: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
inverted: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
overlay: PropTypes.bool,
|
||||
tabIndex: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: '0',
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
inverted,
|
||||
overlay,
|
||||
pressed,
|
||||
tabIndex,
|
||||
title,
|
||||
} = this.props;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
inverted,
|
||||
overlayed: overlay,
|
||||
});
|
||||
|
||||
// Perf optimization: avoid unnecessary <Motion> components unless we actually need to animate.
|
||||
if (!animate) {
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) => (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,114 +1 @@
|
||||
import classNames from 'classnames';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import Motion from '../../features/ui/util/optional_motion';
|
||||
import Icon from '../icon';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export default class IconButton extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
pressed: PropTypes.bool,
|
||||
expanded: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
inverted: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
overlay: PropTypes.bool,
|
||||
tabIndex: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: '0',
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
inverted,
|
||||
overlay,
|
||||
pressed,
|
||||
tabIndex,
|
||||
title,
|
||||
} = this.props;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
inverted,
|
||||
overlayed: overlay,
|
||||
});
|
||||
|
||||
// Perf optimization: avoid unnecessary <Motion> components unless we actually need to animate.
|
||||
if (!animate) {
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) => (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './icon_button';
|
@ -1,6 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import { LoadingBar } from 'react-redux-loading-bar';
|
||||
import ZoomableImage from './zoomable_image';
|
||||
import ZoomableImage from '../zoomable_image';
|
||||
|
||||
import './image_loader.scss';
|
||||
|
||||
export default class ImageLoader extends PureComponent {
|
||||
|
||||
@ -32,6 +34,7 @@ export default class ImageLoader extends PureComponent {
|
||||
if (!this.canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
|
||||
return this._canvasContext;
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
.image-loader {
|
||||
position: relative;
|
||||
|
||||
@include flex(center, center, column);
|
||||
@include size(100%);
|
||||
|
||||
&__preview-canvas {
|
||||
object-fit: contain;
|
||||
|
||||
@include max-size($media-modal-media-max-width, $media-modal-media-max-height);
|
||||
@include background-image('/assets/images/void.png', contain, center, repeat);
|
||||
}
|
||||
|
||||
&--amorphous & {
|
||||
&__preview-canvas {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
position: relative;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './image_loader';
|
@ -1,136 +1 @@
|
||||
import { is } from 'immutable';
|
||||
import scheduleIdleTask from '../../utils/schedule_idle_task';
|
||||
import getRectFromEntry from '../../utils/get_rect_from_entry';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
// Diff these props in the "rendered" state
|
||||
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
||||
export default class IntersectionObserverArticle extends Component {
|
||||
|
||||
static propTypes = {
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
saveHeightKey: PropTypes.string,
|
||||
cachedHeight: PropTypes.number,
|
||||
onHeightChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
|
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
|
||||
|
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
if (!!isUnrendered !== !!willBeUnrendered) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, diff based on props
|
||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
|
||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
|
||||
intersectionObserverWrapper.observe(
|
||||
id,
|
||||
this.node,
|
||||
this.handleIntersection
|
||||
);
|
||||
|
||||
this.componentMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
intersectionObserverWrapper.unobserve(id, this.node);
|
||||
|
||||
this.componentMounted = false;
|
||||
}
|
||||
|
||||
handleIntersection = (entry) => {
|
||||
this.entry = entry;
|
||||
|
||||
scheduleIdleTask(this.calculateHeight);
|
||||
this.setState(this.updateStateAfterIntersection);
|
||||
}
|
||||
|
||||
updateStateAfterIntersection = (prevState) => {
|
||||
if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
|
||||
scheduleIdleTask(this.hideIfNotIntersecting);
|
||||
}
|
||||
|
||||
return {
|
||||
isIntersecting: this.entry.isIntersecting,
|
||||
isHidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
calculateHeight = () => {
|
||||
const { onHeightChange, saveHeightKey, id } = this.props;
|
||||
// Save the height of the fully-rendered element (this is expensive
|
||||
// on Chrome, where we need to fall back to getBoundingClientRect)
|
||||
this.height = getRectFromEntry(this.entry).height;
|
||||
|
||||
if (onHeightChange && saveHeightKey) {
|
||||
onHeightChange(saveHeightKey, id, this.height);
|
||||
}
|
||||
}
|
||||
|
||||
hideIfNotIntersecting = () => {
|
||||
if (!this.componentMounted) return;
|
||||
|
||||
// When the browser gets a chance, test if we're still not intersecting,
|
||||
// and if so, set our isHidden to true to trigger an unrender. The point of
|
||||
// this is to save DOM nodes and avoid using up too much memory.
|
||||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, id, index, listLength, cachedHeight } = this.props;
|
||||
const { isIntersecting, isHidden } = this.state;
|
||||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||
return (
|
||||
<article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||
data-id={id}
|
||||
tabIndex='0'
|
||||
>
|
||||
{children && React.cloneElement(children, { hidden: true })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
data-id={id}
|
||||
tabIndex='0'
|
||||
>
|
||||
{children && React.cloneElement(children, { hidden: false })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export { default } from './intersection_observer_article';
|
@ -0,0 +1,136 @@
|
||||
import { is } from 'immutable';
|
||||
import scheduleIdleTask from '../../utils/schedule_idle_task';
|
||||
import getRectFromEntry from '../../utils/get_rect_from_entry';
|
||||
|
||||
import './intersection_observer_article.scss';
|
||||
|
||||
// Diff these props in the "rendered" state
|
||||
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
||||
export default class IntersectionObserverArticle extends Component {
|
||||
|
||||
static propTypes = {
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
saveHeightKey: PropTypes.string,
|
||||
cachedHeight: PropTypes.number,
|
||||
onHeightChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
|
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
|
||||
|
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
if (!!isUnrendered !== !!willBeUnrendered) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, diff based on props
|
||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
|
||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
|
||||
intersectionObserverWrapper.observe(
|
||||
id,
|
||||
this.node,
|
||||
this.handleIntersection
|
||||
);
|
||||
|
||||
this.componentMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
intersectionObserverWrapper.unobserve(id, this.node);
|
||||
|
||||
this.componentMounted = false;
|
||||
}
|
||||
|
||||
handleIntersection = (entry) => {
|
||||
this.entry = entry;
|
||||
|
||||
scheduleIdleTask(this.calculateHeight);
|
||||
this.setState(this.updateStateAfterIntersection);
|
||||
}
|
||||
|
||||
updateStateAfterIntersection = (prevState) => {
|
||||
if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
|
||||
scheduleIdleTask(this.hideIfNotIntersecting);
|
||||
}
|
||||
|
||||
return {
|
||||
isIntersecting: this.entry.isIntersecting,
|
||||
isHidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
calculateHeight = () => {
|
||||
const { onHeightChange, saveHeightKey, id } = this.props;
|
||||
// Save the height of the fully-rendered element (this is expensive
|
||||
// on Chrome, where we need to fall back to getBoundingClientRect)
|
||||
this.height = getRectFromEntry(this.entry).height;
|
||||
|
||||
if (onHeightChange && saveHeightKey) {
|
||||
onHeightChange(saveHeightKey, id, this.height);
|
||||
}
|
||||
}
|
||||
|
||||
hideIfNotIntersecting = () => {
|
||||
if (!this.componentMounted) return;
|
||||
|
||||
// When the browser gets a chance, test if we're still not intersecting,
|
||||
// and if so, set our isHidden to true to trigger an unrender. The point of
|
||||
// this is to save DOM nodes and avoid using up too much memory.
|
||||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, id, index, listLength, cachedHeight } = this.props;
|
||||
const { isIntersecting, isHidden } = this.state;
|
||||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||
return (
|
||||
<article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||
data-id={id}
|
||||
tabIndex='0'
|
||||
>
|
||||
{children && React.cloneElement(children, { hidden: true })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
data-id={id}
|
||||
tabIndex='0'
|
||||
>
|
||||
{children && React.cloneElement(children, { hidden: false })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
1
app/javascript/gabsocial/components/link_footer/index.js
Normal file
1
app/javascript/gabsocial/components/link_footer/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './link_footer';
|
@ -0,0 +1,64 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { invitesEnabled, version, repository, source_url, me } from '../../initial_state';
|
||||
import { openModal } from '../../actions/modal';
|
||||
|
||||
import './link_footer.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
invite: { id:'getting_started.invite', defaultMessage: 'Invite people' },
|
||||
hotkeys: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Hotkeys' },
|
||||
security: { id: 'getting_started.security', defaultMessage: 'Security' },
|
||||
about: { id: 'navigation_bar.info', defaultMessage: 'About' },
|
||||
developers: { id: 'getting_started.developers', defaultMessage: 'Developers' },
|
||||
terms: { id: 'getting_started.terms', defaultMessage: 'Terms of Service' },
|
||||
dmca: { id: 'getting_started.dmca', defaultMessage: 'DMCA' },
|
||||
terms: { id: 'getting_started.terms_of_sale', defaultMessage: 'Terms of Sale' },
|
||||
privacy: { id: 'getting_started.privacy', defaultMessage: 'Privacy Policy' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
notice: { id: 'getting_started.open_source_notice', defaultMessage: 'Gab Social is open source software. You can contribute or report issues on our self-hosted GitLab at {gitlab}.' },
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onOpenHotkeys() {
|
||||
dispatch(openModal('HOTKEYS'));
|
||||
},
|
||||
});
|
||||
|
||||
export default @connect(null, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class LinkFooter extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
onOpenHotkeys: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onOpenHotkeys, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='link-footer'>
|
||||
<ul>
|
||||
{(invitesEnabled && me) && <li><a href='/invites'>{intl.formatMessage(messages.invite)}</a> · </li>}
|
||||
{me && <li><a href='#' onClick={onOpenHotkeys}>{intl.formatMessage(messages.hotkeys)}</a> · </li>}
|
||||
{me && <li><a href='/auth/edit'>{intl.formatMessage(messages.security)}</a> · </li>}
|
||||
<li><a href='/about'>{intl.formatMessage(messages.about)}</a> · </li>
|
||||
<li><a href='/settings/applications'>{intl.formatMessage(messages.developers)}</a> · </li>
|
||||
<li><a href='/about/tos'>{intl.formatMessage(messages.terms)}</a> · </li>
|
||||
<li><a href='/about/dmca'>{intl.formatMessage(messages.dmca)}</a> · </li>
|
||||
<li><a href='/about/sales'>{intl.formatMessage(messages.terms)}</a> · </li>
|
||||
<li><a href='/about/privacy'>{intl.formatMessage(messages.privacy)}</a></li>
|
||||
{me && <li> · <a href='/auth/sign_out' data-method='delete'>{intl.formatMessage(messages.logout)}</a></li>}
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
{intl.formatMessage(messages.invite, {
|
||||
gitlab: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span>
|
||||
})}
|
||||
</p>
|
||||
<p>© 2019 Gab AI Inc.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user