From 45c44989c8fb6e24badd18bb83ac5f68de0aceaf Mon Sep 17 00:00:00 2001 From: kibigo! Date: Fri, 17 Nov 2017 19:11:18 -0800 Subject: Forking glitch theme --- .../ui/components/__tests__/column-test.js | 34 ++ .../glitch/features/ui/components/actions_modal.js | 74 +++ .../glitch/features/ui/components/boost_modal.js | 84 +++ .../themes/glitch/features/ui/components/bundle.js | 102 ++++ .../features/ui/components/bundle_column_error.js | 44 ++ .../features/ui/components/bundle_modal_error.js | 53 ++ .../themes/glitch/features/ui/components/column.js | 74 +++ .../glitch/features/ui/components/column_header.js | 35 ++ .../glitch/features/ui/components/column_link.js | 39 ++ .../features/ui/components/column_loading.js | 30 + .../features/ui/components/column_subheading.js | 16 + .../glitch/features/ui/components/columns_area.js | 174 ++++++ .../features/ui/components/confirmation_modal.js | 53 ++ .../glitch/features/ui/components/doodle_modal.js | 614 +++++++++++++++++++++ .../features/ui/components/drawer_loading.js | 11 + .../glitch/features/ui/components/embed_modal.js | 84 +++ .../glitch/features/ui/components/image_loader.js | 152 +++++ .../glitch/features/ui/components/media_modal.js | 126 +++++ .../glitch/features/ui/components/modal_loading.js | 20 + .../glitch/features/ui/components/modal_root.js | 131 +++++ .../glitch/features/ui/components/mute_modal.js | 105 ++++ .../features/ui/components/onboarding_modal.js | 323 +++++++++++ .../glitch/features/ui/components/report_modal.js | 105 ++++ .../glitch/features/ui/components/tabs_bar.js | 84 +++ .../glitch/features/ui/components/upload_area.js | 52 ++ .../glitch/features/ui/components/video_modal.js | 33 ++ 26 files changed, 2652 insertions(+) create mode 100644 app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js create mode 100644 app/javascript/themes/glitch/features/ui/components/actions_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/boost_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/bundle.js create mode 100644 app/javascript/themes/glitch/features/ui/components/bundle_column_error.js create mode 100644 app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js create mode 100644 app/javascript/themes/glitch/features/ui/components/column.js create mode 100644 app/javascript/themes/glitch/features/ui/components/column_header.js create mode 100644 app/javascript/themes/glitch/features/ui/components/column_link.js create mode 100644 app/javascript/themes/glitch/features/ui/components/column_loading.js create mode 100644 app/javascript/themes/glitch/features/ui/components/column_subheading.js create mode 100644 app/javascript/themes/glitch/features/ui/components/columns_area.js create mode 100644 app/javascript/themes/glitch/features/ui/components/confirmation_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/doodle_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/drawer_loading.js create mode 100644 app/javascript/themes/glitch/features/ui/components/embed_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/image_loader.js create mode 100644 app/javascript/themes/glitch/features/ui/components/media_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/modal_loading.js create mode 100644 app/javascript/themes/glitch/features/ui/components/modal_root.js create mode 100644 app/javascript/themes/glitch/features/ui/components/mute_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/onboarding_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/report_modal.js create mode 100644 app/javascript/themes/glitch/features/ui/components/tabs_bar.js create mode 100644 app/javascript/themes/glitch/features/ui/components/upload_area.js create mode 100644 app/javascript/themes/glitch/features/ui/components/video_modal.js (limited to 'app/javascript/themes/glitch/features/ui/components') diff --git a/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js b/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js new file mode 100644 index 000000000..1e5e1d8dc --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Column from '../column'; +import ColumnHeader from '../column_header'; + +describe('', () => { + describe(' click handler', () => { + const originalRaf = global.requestAnimationFrame; + + beforeEach(() => { + global.requestAnimationFrame = jest.fn(); + }); + + afterAll(() => { + global.requestAnimationFrame = originalRaf; + }); + + it('runs the scroll animation if the column contains scrollable content', () => { + const wrapper = mount( + +
+ + ); + wrapper.find(ColumnHeader).simulate('click'); + expect(global.requestAnimationFrame.mock.calls.length).toEqual(1); + }); + + it('does not try to scroll if there is no scrollable content', () => { + const wrapper = mount(); + wrapper.find(ColumnHeader).simulate('click'); + expect(global.requestAnimationFrame.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/app/javascript/themes/glitch/features/ui/components/actions_modal.js b/app/javascript/themes/glitch/features/ui/components/actions_modal.js new file mode 100644 index 000000000..7a2b78b63 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/actions_modal.js @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from 'themes/glitch/components/status_content'; +import Avatar from 'themes/glitch/components/avatar'; +import RelativeTimestamp from 'themes/glitch/components/relative_timestamp'; +import DisplayName from 'themes/glitch/components/display_name'; +import IconButton from 'themes/glitch/components/icon_button'; +import classNames from 'classnames'; + +export default class ActionsModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + actions: PropTypes.array, + onClick: PropTypes.func, + }; + + renderAction = (action, i) => { + if (action === null) { + return
  • ; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + + return ( +
  • + + {icon && } +
    +
    {text}
    +
    {meta}
    +
    +
    +
  • + ); + } + + render () { + const status = this.props.status && ( +
    + + + +
    + ); + + return ( +
    + {status} + +
      + {this.props.actions.map(this.renderAction)} +
    +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/boost_modal.js b/app/javascript/themes/glitch/features/ui/components/boost_modal.js new file mode 100644 index 000000000..49781db10 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/boost_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'themes/glitch/components/button'; +import StatusContent from 'themes/glitch/components/status_content'; +import Avatar from 'themes/glitch/components/avatar'; +import RelativeTimestamp from 'themes/glitch/components/relative_timestamp'; +import DisplayName from 'themes/glitch/components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, +}); + +@injectIntl +export default class BoostModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleReblog = () => { + this.props.onReblog(this.props.status); + this.props.onClose(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { status, intl } = this.props; + + return ( +
    +
    +
    +
    +
    + +
    + + +
    + +
    + + +
    +
    + + +
    +
    + +
    +
    Shift + }} />
    +
    +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/bundle.js b/app/javascript/themes/glitch/features/ui/components/bundle.js new file mode 100644 index 000000000..fc88e0c70 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends React.Component { + + static propTypes = { + fetchComponent: PropTypes.func.isRequired, + loading: PropTypes.func, + error: PropTypes.func, + children: PropTypes.func.isRequired, + renderDelay: PropTypes.number, + onFetch: PropTypes.func, + onFetchSuccess: PropTypes.func, + onFetchFail: PropTypes.func, + } + + static defaultProps = { + loading: emptyComponent, + error: emptyComponent, + renderDelay: 0, + onFetch: noop, + onFetchSuccess: noop, + onFetchFail: noop, + } + + static cache = {} + + state = { + mod: undefined, + forceRender: false, + } + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fetchComponent !== this.props.fetchComponent) { + this.load(nextProps); + } + } + + componentWillUnmount () { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + load = (props) => { + const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + + onFetch(); + + if (Bundle.cache[fetchComponent.name]) { + const mod = Bundle.cache[fetchComponent.name]; + + this.setState({ mod: mod.default }); + onFetchSuccess(); + return Promise.resolve(); + } + + this.setState({ mod: undefined }); + + if (renderDelay !== 0) { + this.timestamp = new Date(); + this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); + } + + return fetchComponent() + .then((mod) => { + Bundle.cache[fetchComponent.name] = mod; + this.setState({ mod: mod.default }); + onFetchSuccess(); + }) + .catch((error) => { + this.setState({ mod: null }); + onFetchFail(error); + }); + } + + render() { + const { loading: Loading, error: Error, children, renderDelay } = this.props; + const { mod, forceRender } = this.state; + const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; + + if (mod === undefined) { + return (elapsed >= renderDelay || forceRender) ? : null; + } + + if (mod === null) { + return ; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js new file mode 100644 index 000000000..daedc6299 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import Column from './column'; +import ColumnHeader from './column_header'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import IconButton from 'themes/glitch/components/icon_button'; + +const messages = defineMessages({ + title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, + body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, +}); + +class BundleColumnError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + + + +
    + + {formatMessage(messages.body)} +
    +
    + ); + } + +} + +export default injectIntl(BundleColumnError); diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js new file mode 100644 index 000000000..8cca32ae9 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import IconButton from 'themes/glitch/components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +class BundleModalError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { onClose, intl: { formatMessage } } = this.props; + + // Keep the markup in sync with + // (make sure they have the same dimensions) + return ( +
    +
    + + {formatMessage(messages.error)} +
    + +
    +
    + +
    +
    +
    + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/themes/glitch/features/ui/components/column.js b/app/javascript/themes/glitch/features/ui/components/column.js new file mode 100644 index 000000000..73a5bc15e --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column.js @@ -0,0 +1,74 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { scrollTop } from 'themes/glitch/util/scroll'; +import { isMobile } from 'themes/glitch/util/is_mobile'; + +export default class Column extends React.PureComponent { + + static propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, + }; + + handleHeaderClick = () => { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + + handleScroll = debounce(() => { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + }, 200) + + setRef = (c) => { + this.node = c; + } + + render () { + const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props; + + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); + + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + + ); + return ( +
    + {header} + {children} +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_header.js b/app/javascript/themes/glitch/features/ui/components/column_header.js new file mode 100644 index 000000000..af195ea9c --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_header.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class ColumnHeader extends React.PureComponent { + + static propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + columnHeaderId: PropTypes.string, + }; + + handleClick = () => { + this.props.onClick(); + } + + render () { + const { type, active, columnHeaderId } = this.props; + + let icon = ''; + + if (this.props.icon) { + icon = ; + } + + return ( +
    + {icon} + {type} +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_link.js b/app/javascript/themes/glitch/features/ui/components/column_link.js new file mode 100644 index 000000000..b845d1895 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_link.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +const ColumnLink = ({ icon, text, to, onClick, href, method }) => { + if (href) { + return ( + + + {text} + + ); + } else if (to) { + return ( + + + {text} + + ); + } else { + return ( + + + {text} + + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + onClick: PropTypes.func, + href: PropTypes.string, + method: PropTypes.string, +}; + +export default ColumnLink; diff --git a/app/javascript/themes/glitch/features/ui/components/column_loading.js b/app/javascript/themes/glitch/features/ui/components/column_loading.js new file mode 100644 index 000000000..75f26218a --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_loading.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class ColumnLoading extends ImmutablePureComponent { + + static propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + icon: PropTypes.string, + }; + + static defaultProps = { + title: '', + icon: '', + }; + + render() { + let { title, icon } = this.props; + return ( + + +
    + + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_subheading.js b/app/javascript/themes/glitch/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_subheading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( +
    + {text} +
    + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/themes/glitch/features/ui/components/columns_area.js b/app/javascript/themes/glitch/features/ui/components/columns_area.js new file mode 100644 index 000000000..452950363 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/columns_area.js @@ -0,0 +1,174 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import ReactSwipeableViews from 'react-swipeable-views'; +import { links, getIndex, getLink } from './tabs_bar'; + +import BundleContainer from '../containers/bundle_container'; +import ColumnLoading from './column_loading'; +import DrawerLoading from './drawer_loading'; +import BundleColumnError from './bundle_column_error'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from 'themes/glitch/util/async-components'; + +import detectPassiveEvents from 'detect-passive-events'; +import { scrollRight } from 'themes/glitch/util/scroll'; + +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, + 'FAVOURITES': FavouritedStatuses, +}; + +@component => injectIntl(component, { withRef: true }) +export default class ColumnsArea extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + columns: ImmutablePropTypes.list.isRequired, + singleColumn: PropTypes.bool, + children: PropTypes.node, + }; + + state = { + shouldAnimate: false, + } + + componentWillReceiveProps() { + this.setState({ shouldAnimate: false }); + } + + componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth); + } + } + + handleSwipe = (index) => { + this.pendingIndex = index; + + const nextLinkTranslationId = links[index].props['data-preview-title-id']; + const currentLinkSelector = '.tabs-bar__link.active'; + const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`; + + // HACK: Remove the active class from the current link and set it to the next one + // React-router does this for us, but too late, feeling laggy. + document.querySelector(currentLinkSelector).classList.remove('active'); + document.querySelector(nextLinkSelector).classList.add('active'); + } + + handleAnimationEnd = () => { + if (typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = (node) => { + this.node = node; + } + + renderView = (link, index) => { + const columnIndex = getIndex(this.context.router.history.location.pathname); + const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); + const icon = link.props['data-preview-icon']; + + const view = (index === columnIndex) ? + React.cloneElement(this.props.children) : + ; + + return ( +
    + {view} +
    + ); + } + + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? : ; + } + + renderError = (props) => { + return ; + } + + render () { + const { columns, children, singleColumn } = this.props; + const { shouldAnimate } = this.state; + + const columnIndex = getIndex(this.context.router.history.location.pathname); + this.pendingIndex = null; + + if (singleColumn) { + return columnIndex !== -1 ? ( + + {links.map(this.renderView)} + + ) :
    {children}
    ; + } + + return ( +
    + {columns.map(column => { + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + + return ( + + {SpecificComponent => } + + ); + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..3d568aec3 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'themes/glitch/components/button'; + +@injectIntl +export default class ConfirmationModal extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { message, confirm } = this.props; + + return ( +
    +
    + {message} +
    + +
    + +
    +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/doodle_modal.js b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js new file mode 100644 index 000000000..819656dbf --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js @@ -0,0 +1,614 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'themes/glitch/components/button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Atrament from 'atrament'; // the doodling library +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { doodleSet, uploadCompose } from 'themes/glitch/actions/compose'; +import IconButton from 'themes/glitch/components/icon_button'; +import { debounce, mapValues } from 'lodash'; +import classNames from 'classnames'; + +// palette nicked from MyPaint, CC0 +const palette = [ + ['rgb( 0, 0, 0)', 'Black'], + ['rgb( 38, 38, 38)', 'Gray 15'], + ['rgb( 77, 77, 77)', 'Grey 30'], + ['rgb(128, 128, 128)', 'Grey 50'], + ['rgb(171, 171, 171)', 'Grey 67'], + ['rgb(217, 217, 217)', 'Grey 85'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(128, 0, 0)', 'Maroon'], + ['rgb(209, 0, 0)', 'English-red'], + ['rgb(255, 54, 34)', 'Tomato'], + ['rgb(252, 60, 3)', 'Orange-red'], + ['rgb(255, 140, 105)', 'Salmon'], + ['rgb(252, 232, 32)', 'Cadium-yellow'], + ['rgb(243, 253, 37)', 'Lemon yellow'], + ['rgb(121, 5, 35)', 'Dark crimson'], + ['rgb(169, 32, 62)', 'Deep carmine'], + ['rgb(255, 140, 0)', 'Orange'], + ['rgb(255, 168, 18)', 'Dark tangerine'], + ['rgb(217, 144, 88)', 'Persian orange'], + ['rgb(194, 178, 128)', 'Sand'], + ['rgb(255, 229, 180)', 'Peach'], + ['rgb(100, 54, 46)', 'Bole'], + ['rgb(108, 41, 52)', 'Dark cordovan'], + ['rgb(163, 65, 44)', 'Chestnut'], + ['rgb(228, 136, 100)', 'Dark salmon'], + ['rgb(255, 195, 143)', 'Apricot'], + ['rgb(255, 219, 188)', 'Unbleached silk'], + ['rgb(242, 227, 198)', 'Straw'], + ['rgb( 53, 19, 13)', 'Bistre'], + ['rgb( 84, 42, 14)', 'Dark chocolate'], + ['rgb(102, 51, 43)', 'Burnt sienna'], + ['rgb(184, 66, 0)', 'Sienna'], + ['rgb(216, 153, 12)', 'Yellow ochre'], + ['rgb(210, 180, 140)', 'Tan'], + ['rgb(232, 204, 144)', 'Dark wheat'], + ['rgb( 0, 49, 83)', 'Prussian blue'], + ['rgb( 48, 69, 119)', 'Dark grey blue'], + ['rgb( 0, 71, 171)', 'Cobalt blue'], + ['rgb( 31, 117, 254)', 'Blue'], + ['rgb(120, 180, 255)', 'Bright french blue'], + ['rgb(171, 200, 255)', 'Bright steel blue'], + ['rgb(208, 231, 255)', 'Ice blue'], + ['rgb( 30, 51, 58)', 'Medium jungle green'], + ['rgb( 47, 79, 79)', 'Dark slate grey'], + ['rgb( 74, 104, 93)', 'Dark grullo green'], + ['rgb( 0, 128, 128)', 'Teal'], + ['rgb( 67, 170, 176)', 'Turquoise'], + ['rgb(109, 174, 199)', 'Cerulean frost'], + ['rgb(173, 217, 186)', 'Tiffany green'], + ['rgb( 22, 34, 29)', 'Gray-asparagus'], + ['rgb( 36, 48, 45)', 'Medium dark teal'], + ['rgb( 74, 104, 93)', 'Xanadu'], + ['rgb(119, 198, 121)', 'Mint'], + ['rgb(175, 205, 182)', 'Timberwolf'], + ['rgb(185, 245, 246)', 'Celeste'], + ['rgb(193, 255, 234)', 'Aquamarine'], + ['rgb( 29, 52, 35)', 'Cal Poly Pomona'], + ['rgb( 1, 68, 33)', 'Forest green'], + ['rgb( 42, 128, 0)', 'Napier green'], + ['rgb(128, 128, 0)', 'Olive'], + ['rgb( 65, 156, 105)', 'Sea green'], + ['rgb(189, 246, 29)', 'Green-yellow'], + ['rgb(231, 244, 134)', 'Bright chartreuse'], + ['rgb(138, 23, 137)', 'Purple'], + ['rgb( 78, 39, 138)', 'Violet'], + ['rgb(193, 75, 110)', 'Dark thulian pink'], + ['rgb(222, 49, 99)', 'Cerise'], + ['rgb(255, 20, 147)', 'Deep pink'], + ['rgb(255, 102, 204)', 'Rose pink'], + ['rgb(255, 203, 219)', 'Pink'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(229, 17, 1)', 'RGB Red'], + ['rgb( 0, 255, 0)', 'RGB Green'], + ['rgb( 0, 0, 255)', 'RGB Blue'], + ['rgb( 0, 255, 255)', 'CMYK Cyan'], + ['rgb(255, 0, 255)', 'CMYK Magenta'], + ['rgb(255, 255, 0)', 'CMYK Yellow'], +]; + +// re-arrange to the right order for display +let palReordered = []; +for (let row = 0; row < 7; row++) { + for (let col = 0; col < 11; col++) { + palReordered.push(palette[col * 7 + row]); + } + palReordered.push(null); // null indicates a
    +} + +// Utility for converting base64 image to binary for upload +// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f +function dataURLtoFile(dataurl, filename) { + let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} + +const DOODLE_SIZES = { + normal: [500, 500, 'Square 500'], + tootbanner: [702, 330, 'Tootbanner'], + s640x480: [640, 480, '640×480 - 480p'], + s800x600: [800, 600, '800×600 - SVGA'], + s720x480: [720, 405, '720x405 - 16:9'], +}; + + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'doodle']), +}); + +const mapDispatchToProps = dispatch => ({ + /** Set options in the redux store */ + setOpt: (opts) => dispatch(doodleSet(opts)), + /** Submit doodle for upload */ + submit: (file) => dispatch(uploadCompose([file])), +}); + +/** + * Doodling dialog with drawing canvas + * + * Keyboard shortcuts: + * - Delete: Clear screen, fill with background color + * - Backspace, Ctrl+Z: Undo one step + * - Ctrl held while drawing: Use background color + * - Shift held while clicking screen: Use fill tool + * + * Palette: + * - Left mouse button: pick foreground + * - Ctrl + left mouse button: pick background + * - Right mouse button: pick background + */ +@connect(mapStateToProps, mapDispatchToProps) +export default class DoodleModal extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + setOpt: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + }; + + //region Option getters/setters + + /** Foreground color */ + get fg () { + return this.props.options.get('fg'); + } + set fg (value) { + this.props.setOpt({ fg: value }); + } + + /** Background color */ + get bg () { + return this.props.options.get('bg'); + } + set bg (value) { + this.props.setOpt({ bg: value }); + } + + /** Swap Fg and Bg for drawing */ + get swapped () { + return this.props.options.get('swapped'); + } + set swapped (value) { + this.props.setOpt({ swapped: value }); + } + + /** Mode - 'draw' or 'fill' */ + get mode () { + return this.props.options.get('mode'); + } + set mode (value) { + this.props.setOpt({ mode: value }); + } + + /** Base line weight */ + get weight () { + return this.props.options.get('weight'); + } + set weight (value) { + this.props.setOpt({ weight: value }); + } + + /** Drawing opacity */ + get opacity () { + return this.props.options.get('opacity'); + } + set opacity (value) { + this.props.setOpt({ opacity: value }); + } + + /** Adaptive stroke - change width with speed */ + get adaptiveStroke () { + return this.props.options.get('adaptiveStroke'); + } + set adaptiveStroke (value) { + this.props.setOpt({ adaptiveStroke: value }); + } + + /** Smoothing (for mouse drawing) */ + get smoothing () { + return this.props.options.get('smoothing'); + } + set smoothing (value) { + this.props.setOpt({ smoothing: value }); + } + + /** Size preset */ + get size () { + return this.props.options.get('size'); + } + set size (value) { + this.props.setOpt({ size: value }); + } + + //endregion + + /** Key up handler */ + handleKeyUp = (e) => { + if (e.target.nodeName === 'INPUT') return; + + if (e.key === 'Delete') { + e.preventDefault(); + this.handleClearBtn(); + return; + } + + if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + this.undo(); + } + + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = false; + this.swapped = false; + } + + if (e.key === 'Shift') { + this.shiftHeld = false; + this.mode = 'draw'; + } + }; + + /** Key down handler */ + handleKeyDown = (e) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = true; + this.swapped = true; + } + + if (e.key === 'Shift') { + this.shiftHeld = true; + this.mode = 'fill'; + } + }; + + /** + * Component installed in the DOM, do some initial set-up + */ + componentDidMount () { + this.controlHeld = false; + this.shiftHeld = false; + this.swapped = false; + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + }; + + /** + * Tear component down + */ + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp, false); + window.removeEventListener('keydown', this.handleKeyDown, false); + if (this.sketcher) this.sketcher.destroy(); + } + + /** + * Set reference to the canvas element. + * This is called during component init + * + * @param elem - canvas element + */ + setCanvasRef = (elem) => { + this.canvas = elem; + if (elem) { + elem.addEventListener('dirty', () => { + this.saveUndo(); + this.sketcher._dirty = false; + }); + + elem.addEventListener('click', () => { + // sketcher bug - does not fire dirty on fill + if (this.mode === 'fill') { + this.saveUndo(); + } + }); + + // prevent context menu + elem.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + + elem.addEventListener('mousedown', (e) => { + if (e.button === 2) { + this.swapped = true; + } + }); + + elem.addEventListener('mouseup', (e) => { + if (e.button === 2) { + this.swapped = this.controlHeld; + } + }); + + this.initSketcher(elem); + this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill' + } + }; + + /** + * Set up the sketcher instance + * + * @param canvas - canvas element. Null if we're just resizing + */ + initSketcher (canvas = null) { + const sizepreset = DOODLE_SIZES[this.size]; + + if (this.sketcher) this.sketcher.destroy(); + this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); + + if (canvas) { + this.ctx = this.sketcher.context; + this.updateSketcherSettings(); + } + + this.clearScreen(); + } + + /** + * Done button handler + */ + onDoneButton = () => { + const dataUrl = this.sketcher.toImage(); + const file = dataURLtoFile(dataUrl, 'doodle.png'); + this.props.submit(file); + this.props.onClose(); // close dialog + }; + + /** + * Cancel button handler + */ + onCancelButton = () => { + if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { + return; + } + + this.props.onClose(); // close dialog + }; + + /** + * Update sketcher options based on state + */ + updateSketcherSettings () { + if (!this.sketcher) return; + + if (this.oldSize !== this.size) this.initSketcher(); + + this.sketcher.color = (this.swapped ? this.bg : this.fg); + this.sketcher.opacity = this.opacity; + this.sketcher.weight = this.weight; + this.sketcher.mode = this.mode; + this.sketcher.smoothing = this.smoothing; + this.sketcher.adaptiveStroke = this.adaptiveStroke; + + this.oldSize = this.size; + } + + /** + * Fill screen with background color + */ + clearScreen = () => { + this.ctx.fillStyle = this.bg; + this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); + this.undos = []; + + this.doSaveUndo(); + }; + + /** + * Undo one step + */ + undo = () => { + if (this.undos.length > 1) { + this.undos.pop(); + const buf = this.undos.pop(); + + this.sketcher.clear(); + this.ctx.putImageData(buf, 0, 0); + this.doSaveUndo(); + } + }; + + /** + * Save canvas content into the undo buffer immediately + */ + doSaveUndo = () => { + this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); + }; + + /** + * Called on each canvas change. + * Saves canvas content to the undo buffer after some period of inactivity. + */ + saveUndo = debounce(() => { + this.doSaveUndo(); + }, 100); + + /** + * Palette left click. + * Selects Fg color (or Bg, if Control/Meta is held) + * + * @param e - event + */ + onPaletteClick = (e) => { + const c = e.target.dataset.color; + + if (this.controlHeld) { + this.bg = c; + } else { + this.fg = c; + } + + e.target.blur(); + e.preventDefault(); + }; + + /** + * Palette right click. + * Selects Bg color + * + * @param e - event + */ + onPaletteRClick = (e) => { + this.bg = e.target.dataset.color; + e.target.blur(); + e.preventDefault(); + }; + + /** + * Handle click on the Draw mode button + * + * @param e - event + */ + setModeDraw = (e) => { + this.mode = 'draw'; + e.target.blur(); + }; + + /** + * Handle click on the Fill mode button + * + * @param e - event + */ + setModeFill = (e) => { + this.mode = 'fill'; + e.target.blur(); + }; + + /** + * Handle click on Smooth checkbox + * + * @param e - event + */ + tglSmooth = (e) => { + this.smoothing = !this.smoothing; + e.target.blur(); + }; + + /** + * Handle click on Adaptive checkbox + * + * @param e - event + */ + tglAdaptive = (e) => { + this.adaptiveStroke = !this.adaptiveStroke; + e.target.blur(); + }; + + /** + * Handle change of the Weight input field + * + * @param e - event + */ + setWeight = (e) => { + this.weight = +e.target.value || 1; + }; + + /** + * Set size - clalback from the select box + * + * @param e - event + */ + changeSize = (e) => { + let newSize = e.target.value; + if (newSize === this.oldSize) return; + + if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) { + return; + } + + this.clearScreen(); + }; + + /** + * Render the component + */ + render () { + this.updateSketcherSettings(); + + return ( +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + +
    +
    +
    + + + + +
    +
    + { + palReordered.map((c, i) => + c === null ? +
    : +
    +
    +
    + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/drawer_loading.js b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js new file mode 100644 index 000000000..08b0d2347 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const DrawerLoading = () => ( +
    +
    +
    +
    +
    +); + +export default DrawerLoading; diff --git a/app/javascript/themes/glitch/features/ui/components/embed_modal.js b/app/javascript/themes/glitch/features/ui/components/embed_modal.js new file mode 100644 index 000000000..1afffb51b --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/embed_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import axios from 'axios'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + axios.post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.width = iframeDocument.body.scrollWidth; + this.iframe.height = iframeDocument.body.scrollHeight; + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( +
    +

    + +
    +

    + +

    + + + +

    + +

    + +