diff options
Diffstat (limited to 'app/javascript/flavours/glitch/components')
6 files changed, 463 insertions, 30 deletions
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js new file mode 100644 index 000000000..39ef216bd --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Counter.js @@ -0,0 +1,115 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/util/api'; +import { FormattedNumber } from 'react-intl'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import classNames from 'classnames'; +import Skeleton from 'flavours/glitch/components/skeleton'; + +const percIncrease = (a, b) => { + let percent; + + if (b !== 0) { + if (a !== 0) { + percent = (b - a) / a; + } else { + percent = 1; + } + } else if (b === 0 && a === 0) { + percent = 0; + } else { + percent = - 1; + } + + return percent; +}; + +export default class Counter extends React.PureComponent { + + static propTypes = { + measure: PropTypes.string.isRequired, + start_at: PropTypes.string.isRequired, + end_at: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + href: PropTypes.string, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { measure, start_at, end_at } = this.props; + + api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { label, href } = this.props; + const { loading, data } = this.state; + + let content; + + if (loading) { + content = ( + <React.Fragment> + <span className='sparkline__value__total'><Skeleton width={43} /></span> + <span className='sparkline__value__change'><Skeleton width={43} /></span> + </React.Fragment> + ); + } else { + const measure = data[0]; + const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1); + + content = ( + <React.Fragment> + <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span> + <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span> + </React.Fragment> + ); + } + + const inner = ( + <React.Fragment> + <div className='sparkline__value'> + {content} + </div> + + <div className='sparkline__label'> + {label} + </div> + + <div className='sparkline__graph'> + {!loading && ( + <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}> + <SparklinesCurve /> + </Sparklines> + )} + </div> + </React.Fragment> + ); + + if (href) { + return ( + <a href={href} className='sparkline'> + {inner} + </a> + ); + } else { + return ( + <div className='sparkline'> + {inner} + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.js b/app/javascript/flavours/glitch/components/admin/Dimension.js new file mode 100644 index 000000000..b4fbf86c8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Dimension.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/util/api'; +import { FormattedNumber } from 'react-intl'; +import { roundTo10 } from 'flavours/glitch/util/numbers'; +import Skeleton from 'flavours/glitch/components/skeleton'; + +export default class Dimension extends React.PureComponent { + + static propTypes = { + dimension: PropTypes.string.isRequired, + start_at: PropTypes.string.isRequired, + end_at: PropTypes.string.isRequired, + limit: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { start_at, end_at, dimension, limit } = this.props; + + api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { label, limit } = this.props; + const { loading, data } = this.state; + + let content; + + if (loading) { + content = ( + <table> + <tbody> + {Array.from(Array(limit)).map((_, i) => ( + <tr className='dimension__item' key={i}> + <td className='dimension__item__key'> + <Skeleton width={100} /> + </td> + + <td className='dimension__item__value'> + <Skeleton width={60} /> + </td> + </tr> + ))} + </tbody> + </table> + ); + } else { + const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0); + + content = ( + <table> + <tbody> + {data[0].data.map(item => ( + <tr className='dimension__item' key={item.key}> + <td className='dimension__item__key'> + <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} /> + <span title={item.key}>{item.human_key}</span> + </td> + + <td className='dimension__item__value'> + {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />} + </td> + </tr> + ))} + </tbody> + </table> + ); + } + + return ( + <div className='dimension'> + <h4>{label}</h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Retention.js b/app/javascript/flavours/glitch/components/admin/Retention.js new file mode 100644 index 000000000..8295362a4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Retention.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/util/api'; +import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'; +import classNames from 'classnames'; +import { roundTo10 } from 'flavours/glitch/util/numbers'; + +const dateForCohort = cohort => { + switch(cohort.frequency) { + case 'day': + return <FormattedDate value={cohort.period} month='long' day='2-digit' />; + default: + return <FormattedDate value={cohort.period} month='long' year='numeric' />; + } +}; + +export default class Retention extends React.PureComponent { + + static propTypes = { + start_at: PropTypes.string, + end_at: PropTypes.string, + frequency: PropTypes.string, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { start_at, end_at, frequency } = this.props; + + api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { loading, data } = this.state; + + let content; + + if (loading) { + content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />; + } else { + content = ( + <table className='retention__table'> + <thead> + <tr> + <th> + <div className='retention__table__date retention__table__label'> + <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' /> + </div> + </th> + + <th> + <div className='retention__table__number retention__table__label'> + <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' /> + </div> + </th> + + {data[0].data.slice(1).map((retention, i) => ( + <th key={retention.date}> + <div className='retention__table__number retention__table__label'> + {i + 1} + </div> + </th> + ))} + </tr> + + <tr> + <td> + <div className='retention__table__date retention__table__average'> + <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' /> + </div> + </td> + + <td> + <div className='retention__table__size'> + <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> + </div> + </td> + + {data[0].data.slice(1).map((retention, i) => { + const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0); + + return ( + <td key={retention.date}> + <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}> + <FormattedNumber value={average} style='percent' /> + </div> + </td> + ); + })} + </tr> + </thead> + + <tbody> + {data.slice(0, -1).map(cohort => ( + <tr key={cohort.period}> + <td> + <div className='retention__table__date'> + {dateForCohort(cohort)} + </div> + </td> + + <td> + <div className='retention__table__size'> + <FormattedNumber value={cohort.data[0].value} /> + </div> + </td> + + {cohort.data.slice(1).map(retention => ( + <td key={retention.date}> + <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}> + <FormattedNumber value={retention.percent} style='percent' /> + </div> + </td> + ))} + </tr> + ))} + </tbody> + </table> + ); + } + + return ( + <div className='retention'> + <h4><FormattedMessage id='admin.dashboard.retention' defaultMessage='Retention' /></h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js new file mode 100644 index 000000000..d7c4eb72c --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Trends.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/util/api'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Hashtag from 'flavours/glitch/components/hashtag'; + +export default class Trends extends React.PureComponent { + + static propTypes = { + limit: PropTypes.number.isRequired, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { limit } = this.props; + + api().get('/api/v1/admin/trends', { params: { limit } }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { limit } = this.props; + const { loading, data } = this.state; + + let content; + + if (loading) { + content = ( + <div> + {Array.from(Array(limit)).map((_, i) => ( + <Hashtag key={i} /> + ))} + </div> + ); + } else { + content = ( + <div> + {data.map(hashtag => ( + <Hashtag + key={hashtag.name} + name={hashtag.name} + href={`/admin/tags/${hashtag.id}`} + people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1} + uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1} + history={hashtag.history.reverse().map(day => day.uses)} + className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')} + /> + ))} + </div> + ); + } + + return ( + <div className='trends trends--compact'> + <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js index d00c01e77..769185a2b 100644 --- a/app/javascript/flavours/glitch/components/hashtag.js +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -6,6 +6,8 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Permalink from './permalink'; import ShortNumber from 'flavours/glitch/components/short_number'; +import Skeleton from 'flavours/glitch/components/skeleton'; +import classNames from 'classnames'; class SilentErrorBoundary extends React.Component { @@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => ( /> ); -const Hashtag = ({ hashtag }) => ( - <div className='trends__item'> +export const ImmutableHashtag = ({ hashtag }) => ( + <Hashtag + name={hashtag.get('name')} + href={hashtag.get('url')} + to={`/tags/${hashtag.get('name')}`} + people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1} + uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1} + history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()} + /> +); + +ImmutableHashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +const Hashtag = ({ name, href, to, people, uses, history, className }) => ( + <div className={classNames('trends__item', className)}> <div className='trends__item__name'> - <Permalink - href={hashtag.get('url')} - to={`/tags/${hashtag.get('name')}`} - > - #<span>{hashtag.get('name')}</span> + <Permalink href={href} to={to}> + {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />} </Permalink> - <ShortNumber - value={ - hashtag.getIn(['history', 0, 'accounts']) * 1 + - hashtag.getIn(['history', 1, 'accounts']) * 1 - } - renderer={accountsCountRenderer} - /> + {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />} </div> <div className='trends__item__current'> - <ShortNumber - value={ - hashtag.getIn(['history', 0, 'uses']) * 1 + - hashtag.getIn(['history', 1, 'uses']) * 1 - } - /> + {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />} </div> <div className='trends__item__sparkline'> <SilentErrorBoundary> - <Sparklines - width={50} - height={28} - data={hashtag - .get('history') - .reverse() - .map((day) => day.get('uses')) - .toArray()} - > + <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}> <SparklinesCurve style={{ fill: 'none' }} /> </Sparklines> </SilentErrorBoundary> @@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => ( ); Hashtag.propTypes = { - hashtag: ImmutablePropTypes.map.isRequired, + name: PropTypes.string, + href: PropTypes.string, + to: PropTypes.string, + people: PropTypes.number, + uses: PropTypes.number, + history: PropTypes.arrayOf(PropTypes.number), + className: PropTypes.string, }; export default Hashtag; diff --git a/app/javascript/flavours/glitch/components/skeleton.js b/app/javascript/flavours/glitch/components/skeleton.js new file mode 100644 index 000000000..09093e99c --- /dev/null +++ b/app/javascript/flavours/glitch/components/skeleton.js @@ -0,0 +1,11 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>‌</span>; + +Skeleton.propTypes = { + width: PropTypes.number, + height: PropTypes.number, +}; + +export default Skeleton; |