diff options
Diffstat (limited to 'app/javascript/flavours/glitch/components/admin')
5 files changed, 593 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.jsx b/app/javascript/flavours/glitch/components/admin/Counter.jsx new file mode 100644 index 000000000..5b6a19f8d --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Counter.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/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, + params: PropTypes.object, + target: PropTypes.string, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { measure, start_at, end_at, params } = this.props; + + api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { label, href, target } = 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 = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1); + + content = ( + <React.Fragment> + <span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span> + {measure.previous_total && (<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' target={target}> + {inner} + </a> + ); + } else { + return ( + <div className='sparkline'> + {inner} + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.jsx b/app/javascript/flavours/glitch/components/admin/Dimension.jsx new file mode 100644 index 000000000..3dac8c6c2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Dimension.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/api'; +import { FormattedNumber } from 'react-intl'; +import { roundTo10 } from 'flavours/glitch/utils/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, + params: PropTypes.object, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { start_at, end_at, dimension, limit, params } = this.props; + + api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).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/ReportReasonSelector.jsx b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx new file mode 100644 index 000000000..771dbb452 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/api'; +import { injectIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; + +const messages = defineMessages({ + other: { id: 'report.categories.other', defaultMessage: 'Other' }, + spam: { id: 'report.categories.spam', defaultMessage: 'Spam' }, + violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' }, +}); + +class Category extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + selected: PropTypes.bool, + disabled: PropTypes.bool, + onSelect: PropTypes.func, + children: PropTypes.node, + }; + + handleClick = () => { + const { id, disabled, onSelect } = this.props; + + if (!disabled) { + onSelect(id); + } + }; + + render () { + const { id, text, disabled, selected, children } = this.props; + + return ( + <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}> + {selected && <input type='hidden' name='report[category]' value={id} />} + + <div className='report-reason-selector__category__label'> + <span className={classNames('poll__input', { active: selected, disabled })} /> + {text} + </div> + + {(selected && children) && ( + <div className='report-reason-selector__category__rules'> + {children} + </div> + )} + </div> + ); + } + +} + +class Rule extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + selected: PropTypes.bool, + disabled: PropTypes.bool, + onToggle: PropTypes.func, + }; + + handleClick = () => { + const { id, disabled, onToggle } = this.props; + + if (!disabled) { + onToggle(id); + } + }; + + render () { + const { id, text, disabled, selected } = this.props; + + return ( + <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}> + <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} /> + {selected && <input type='hidden' name='report[rule_ids][]' value={id} />} + {text} + </div> + ); + } + +} + +export default @injectIntl +class ReportReasonSelector extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + category: PropTypes.string.isRequired, + rule_ids: PropTypes.arrayOf(PropTypes.string), + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + category: this.props.category, + rule_ids: this.props.rule_ids || [], + rules: [], + }; + + componentDidMount() { + api().get('/api/v1/instance').then(res => { + this.setState({ + rules: res.data.rules, + }); + }).catch(err => { + console.error(err); + }); + } + + _save = () => { + const { id, disabled } = this.props; + const { category, rule_ids } = this.state; + + if (disabled) { + return; + } + + api().put(`/api/v1/admin/reports/${id}`, { + category, + rule_ids, + }).catch(err => { + console.error(err); + }); + }; + + handleSelect = id => { + this.setState({ category: id }, () => this._save()); + }; + + handleToggle = id => { + const { rule_ids } = this.state; + + if (rule_ids.includes(id)) { + this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save()); + } else { + this.setState({ rule_ids: [...rule_ids, id] }, () => this._save()); + } + }; + + render () { + const { disabled, intl } = this.props; + const { rules, category, rule_ids } = this.state; + + return ( + <div className='report-reason-selector'> + <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} /> + <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} /> + <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}> + {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)} + </Category> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Retention.jsx b/app/javascript/flavours/glitch/components/admin/Retention.jsx new file mode 100644 index 000000000..e1ba3f6c9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Retention.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/api'; +import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'; +import classNames from 'classnames'; +import { roundTo10 } from 'flavours/glitch/utils/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; + const { frequency } = this.props; + + 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].rate - 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.rate * 100)}`)}> + <FormattedNumber value={retention.rate} style='percent' /> + </div> + </td> + ))} + </tr> + ))} + </tbody> + </table> + ); + } + + let title = null; + switch(frequency) { + case 'day': + title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />; + break; + default: + title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />; + } + + return ( + <div className='retention'> + <h4>{title}</h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/admin/Trends.jsx b/app/javascript/flavours/glitch/components/admin/Trends.jsx new file mode 100644 index 000000000..774bf36e6 --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/Trends.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'flavours/glitch/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/tags', { 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={hashtag.id === undefined ? undefined : `/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> + ); + } + +} |