diff options
author | Claire <claire.github-309c@sitedethib.com> | 2021-10-14 22:57:41 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-10-14 22:57:41 +0200 |
commit | b6f24ef0fb8b594c38c29090518c21af051b63b7 (patch) | |
tree | 5cec6a6b13dfd062848c74ae8e041c589ae40d00 /app/javascript | |
parent | ebf2c3195615bb524f6908e84f99887c8775cbc3 (diff) | |
parent | 6964952d5f242ca936de9df361185b3e70a99ca4 (diff) |
Merge pull request #1622 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app/javascript')
35 files changed, 1633 insertions, 98 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; diff --git a/app/javascript/flavours/glitch/containers/admin_component.js b/app/javascript/flavours/glitch/containers/admin_component.js new file mode 100644 index 000000000..64dabac8b --- /dev/null +++ b/app/javascript/flavours/glitch/containers/admin_component.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class AdminComponent extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + }; + + render () { + const { locale, children } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + {children} + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index 8657b8064..1ddbc706b 100644 --- a/app/javascript/flavours/glitch/containers/media_container.js +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales'; import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar'; import MediaGallery from 'flavours/glitch/components/media_gallery'; import Poll from 'flavours/glitch/components/poll'; -import Hashtag from 'flavours/glitch/components/hashtag'; +import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag'; import ModalRoot from 'flavours/glitch/components/modal_root'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; import Video from 'flavours/glitch/features/video'; diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js index 9a76e5418..cbc1f35e5 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.js +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import AccountContainer from 'flavours/glitch/containers/account_container'; import StatusContainer from 'flavours/glitch/containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Hashtag from 'flavours/glitch/components/hashtag'; +import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag'; import Icon from 'flavours/glitch/components/icon'; import { searchEnabled } from 'flavours/glitch/util/initial_state'; import LoadMore from 'flavours/glitch/components/load_more'; diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js index 0734ec72b..ce4d94c64 100644 --- a/app/javascript/flavours/glitch/features/getting_started/components/trends.js +++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Hashtag from 'flavours/glitch/components/hashtag'; +import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag'; import { FormattedMessage } from 'react-intl'; export default class Trends extends ImmutablePureComponent { diff --git a/app/javascript/flavours/glitch/packs/admin.js b/app/javascript/flavours/glitch/packs/admin.js new file mode 100644 index 000000000..4c09ddb05 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/admin.js @@ -0,0 +1,24 @@ +import 'packs/public-path'; +import ready from 'flavours/glitch/util/ready'; + +ready(() => { + const React = require('react'); + const ReactDOM = require('react-dom'); + + [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => { + const componentName = element.getAttribute('data-admin-component'); + const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props')); + + import('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => { + return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => { + ReactDOM.render(( + <AdminComponent locale={locale}> + <Component {...componentProps} /> + </AdminComponent> + ), element); + }); + }).catch(error => { + console.error(error); + }); + }); +}); diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 4801a4644..24618c29f 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $no-columns-breakpoint: 600px; $sidebar-width: 240px; $content-width: 840px; @@ -925,10 +927,197 @@ a.name-tag, } } +.dashboard__counters.admin-account-counters { + margin-top: 10px; +} + .account-badges { margin: -2px 0; } -.dashboard__counters.admin-account-counters { - margin-top: 10px; +.retention { + &__table { + &__number { + color: $secondary-text-color; + padding: 10px; + } + + &__date { + white-space: nowrap; + padding: 10px 0; + text-align: left; + min-width: 120px; + + &.retention__table__average { + font-weight: 700; + } + } + + &__size { + text-align: center; + padding: 10px; + } + + &__label { + font-weight: 700; + color: $darker-text-color; + } + + &__box { + box-sizing: border-box; + background: $ui-highlight-color; + padding: 10px; + font-weight: 500; + color: $primary-text-color; + width: 52px; + margin: 1px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + } +} + +.sparkline { + display: block; + text-decoration: none; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + padding: 0; + position: relative; + padding-bottom: 55px + 20px; + overflow: hidden; + + &__value { + display: flex; + line-height: 33px; + align-items: flex-end; + padding: 20px; + padding-bottom: 10px; + + &__total { + display: block; + margin-right: 10px; + font-weight: 500; + font-size: 28px; + color: $primary-text-color; + } + + &__change { + display: block; + font-weight: 500; + font-size: 18px; + color: $darker-text-color; + margin-bottom: -3px; + + &.positive { + color: $valid-value-color; + } + + &.negative { + color: $error-value-color; + } + } + } + + &__label { + padding: 0 20px; + padding-bottom: 10px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + } + + &__graph { + position: absolute; + bottom: 0; + + svg { + display: block; + margin: 0; + } + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !important; + } + } +} + +a.sparkline { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 6%); + } +} + +.skeleton { + background-color: lighten($ui-base-color, 8%); + background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%)); + background-size: 200px 100%; + background-repeat: no-repeat; + border-radius: 4px; + display: inline-block; + line-height: 1; + width: 100%; + animation: skeleton 1.2s ease-in-out infinite; +} + +@keyframes skeleton { + 0% { + background-position: -200px 0; + } + + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.dimension { + table { + width: 100%; + } + + &__item { + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__key { + font-weight: 500; + padding: 11px 10px; + } + + &__value { + text-align: right; + color: $darker-text-color; + padding: 11px 10px; + } + + &__indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: $ui-highlight-color; + margin-right: 10px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + + &:last-child { + border-bottom: 0; + } + } } diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss index 929769130..f7415368b 100644 --- a/app/javascript/flavours/glitch/styles/components/search.scss +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -171,7 +171,6 @@ &__current { flex: 0 0 auto; font-size: 24px; - line-height: 36px; font-weight: 500; text-align: right; padding-right: 15px; @@ -193,5 +192,57 @@ fill: none !important; } } + + &--requires-review { + .trends__item__name { + color: $gold-star; + + a { + color: $gold-star; + } + } + + .trends__item__current { + color: $gold-star; + } + + .trends__item__sparkline { + path:first-child { + fill: rgba($gold-star, 0.25) !important; + } + + path:last-child { + stroke: lighten($gold-star, 6%) !important; + } + } + } + + &--disabled { + .trends__item__name { + color: lighten($ui-base-color, 12%); + + a { + color: lighten($ui-base-color, 12%); + } + } + + .trends__item__current { + color: lighten($ui-base-color, 12%); + } + + .trends__item__sparkline { + path:first-child { + fill: rgba(lighten($ui-base-color, 12%), 0.25) !important; + } + + path:last-child { + stroke: lighten(lighten($ui-base-color, 12%), 6%) !important; + } + } + } + } + + &--compact &__item { + padding: 10px; } } diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss index c0944d417..cad5a105b 100644 --- a/app/javascript/flavours/glitch/styles/dashboard.scss +++ b/app/javascript/flavours/glitch/styles/dashboard.scss @@ -56,23 +56,56 @@ } } -.dashboard__widgets { - display: flex; - flex-wrap: wrap; - margin: 0 -5px; +.dashboard { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + grid-gap: 10px; - & > div { - flex: 0 0 33.333%; - margin-bottom: 20px; + &__item { + &--span-double-column { + grid-column: span 2; + } - & > div { - padding: 0 5px; + &--span-double-row { + grid-row: span 2; + } + + h4 { + padding-top: 20px; } } - a:not(.name-tag) { - color: $ui-secondary-color; - font-weight: 500; + &__quick-access { + display: flex; + align-items: baseline; + border-radius: 4px; + background: $ui-highlight-color; + color: $primary-text-color; + transition: all 100ms ease-in; + font-size: 14px; + padding: 0 16px; + line-height: 36px; + height: 36px; text-decoration: none; + margin-bottom: 4px; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-highlight-color, 10%); + transition: all 200ms ease-out; + } + + span { + flex: 1 1 auto; + } + + .fa { + flex: 0 0 auto; + } + + strong { + font-weight: 700; + } } } diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml index 2a98e4c29..ee2b699b2 100644 --- a/app/javascript/flavours/glitch/theme.yml +++ b/app/javascript/flavours/glitch/theme.yml @@ -1,7 +1,7 @@ # (REQUIRED) The location of the pack files. pack: about: packs/about.js - admin: packs/public.js + admin: packs/admin.js auth: packs/public.js common: filename: packs/common.js diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js index 6f2505cae..6ef563ad8 100644 --- a/app/javascript/flavours/glitch/util/numbers.js +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) { return Math.trunc(sourceNumber / closestScale) * closestScale; } + +/** + * @param {number} num + * @returns {number} + */ +export function roundTo10(num) { + return Math.round(num * 0.1) / 0.1; +} diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml index 74e9fb1b5..3263fd7d4 100644 --- a/app/javascript/flavours/vanilla/theme.yml +++ b/app/javascript/flavours/vanilla/theme.yml @@ -1,7 +1,7 @@ # (REQUIRED) The location of the pack files inside `pack_directory`. pack: about: about.js - admin: public.js + admin: admin.js auth: public.js common: filename: common.js diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js new file mode 100644 index 000000000..cda572dce --- /dev/null +++ b/app/javascript/mastodon/components/admin/Counter.js @@ -0,0 +1,115 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'mastodon/api'; +import { FormattedNumber } from 'react-intl'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import classNames from 'classnames'; +import Skeleton from 'mastodon/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/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js new file mode 100644 index 000000000..ac6dbd1c7 --- /dev/null +++ b/app/javascript/mastodon/components/admin/Dimension.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'mastodon/api'; +import { FormattedNumber } from 'react-intl'; +import { roundTo10 } from 'mastodon/utils/numbers'; +import Skeleton from 'mastodon/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/mastodon/components/admin/Retention.js b/app/javascript/mastodon/components/admin/Retention.js new file mode 100644 index 000000000..aa06722f7 --- /dev/null +++ b/app/javascript/mastodon/components/admin/Retention.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'mastodon/api'; +import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'; +import classNames from 'classnames'; +import { roundTo10 } from 'mastodon/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; + + 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/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js new file mode 100644 index 000000000..46307a28a --- /dev/null +++ b/app/javascript/mastodon/components/admin/Trends.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'mastodon/api'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Hashtag from 'mastodon/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/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index 1a7edf6e0..a793a32f5 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/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 'mastodon/components/short_number'; +import Skeleton from 'mastodon/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/mastodon/components/skeleton.js b/app/javascript/mastodon/components/skeleton.js new file mode 100644 index 000000000..09093e99c --- /dev/null +++ b/app/javascript/mastodon/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; diff --git a/app/javascript/mastodon/containers/admin_component.js b/app/javascript/mastodon/containers/admin_component.js new file mode 100644 index 000000000..816b44bd1 --- /dev/null +++ b/app/javascript/mastodon/containers/admin_component.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class AdminComponent extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + }; + + render () { + const { locale, children } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + {children} + </IntlProvider> + ); + } + +} diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 52fdc9294..2f42a084f 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import MediaGallery from 'mastodon/components/media_gallery'; import Poll from 'mastodon/components/poll'; -import Hashtag from 'mastodon/components/hashtag'; +import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import ModalRoot from 'mastodon/components/modal_root'; import MediaModal from 'mastodon/features/ui/components/media_modal'; import Video from 'mastodon/features/video'; diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index 958a65286..9b3d01cfd 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Hashtag from '../../../components/hashtag'; +import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; import Icon from 'mastodon/components/icon'; import { searchEnabled } from '../../../initial_state'; import LoadMore from 'mastodon/components/load_more'; diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 3b9a3075f..71c7c458d 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Hashtag from 'mastodon/components/hashtag'; +import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { FormattedMessage } from 'react-intl'; export default class Trends extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.js index 6f2505cae..6ef563ad8 100644 --- a/app/javascript/mastodon/utils/numbers.js +++ b/app/javascript/mastodon/utils/numbers.js @@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) { return Math.trunc(sourceNumber / closestScale) * closestScale; } + +/** + * @param {number} num + * @returns {number} + */ +export function roundTo10(num) { + return Math.round(num * 0.1) / 0.1; +} diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js new file mode 100644 index 000000000..599015000 --- /dev/null +++ b/app/javascript/packs/admin.js @@ -0,0 +1,24 @@ +import './public-path'; +import ready from '../mastodon/ready'; + +ready(() => { + const React = require('react'); + const ReactDOM = require('react-dom'); + + [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => { + const componentName = element.getAttribute('data-admin-component'); + const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props')); + + import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => { + return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => { + ReactDOM.render(( + <AdminComponent locale={locale}> + <Component {...componentProps} /> + </AdminComponent> + ), element); + }); + }).catch(error => { + console.error(error); + }); + }); +}); diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss index 80c2329b0..2abcb0c2b 100644 --- a/app/javascript/styles/fonts/montserrat.scss +++ b/app/javascript/styles/fonts/montserrat.scss @@ -5,6 +5,7 @@ url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'), url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); font-weight: 400; + font-display: swap; font-style: normal; } @@ -13,5 +14,6 @@ src: local('Montserrat Medium'), url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); font-weight: 500; + font-display: swap; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss index c793aa6ed..a9513dcce 100644 --- a/app/javascript/styles/fonts/roboto-mono.scss +++ b/app/javascript/styles/fonts/roboto-mono.scss @@ -6,5 +6,6 @@ url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'), url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg'); font-weight: 400; + font-display: swap; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss index b75fdb927..817b448c1 100644 --- a/app/javascript/styles/fonts/roboto.scss +++ b/app/javascript/styles/fonts/roboto.scss @@ -6,6 +6,7 @@ url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'), url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg'); font-weight: normal; + font-display: swap; font-style: italic; } @@ -17,6 +18,7 @@ url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'), url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg'); font-weight: bold; + font-display: swap; font-style: normal; } @@ -28,6 +30,7 @@ url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'), url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg'); font-weight: 500; + font-display: swap; font-style: normal; } @@ -39,5 +42,6 @@ url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'), url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg'); font-weight: normal; + font-display: swap; font-style: normal; } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4801a4644..24618c29f 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $no-columns-breakpoint: 600px; $sidebar-width: 240px; $content-width: 840px; @@ -925,10 +927,197 @@ a.name-tag, } } +.dashboard__counters.admin-account-counters { + margin-top: 10px; +} + .account-badges { margin: -2px 0; } -.dashboard__counters.admin-account-counters { - margin-top: 10px; +.retention { + &__table { + &__number { + color: $secondary-text-color; + padding: 10px; + } + + &__date { + white-space: nowrap; + padding: 10px 0; + text-align: left; + min-width: 120px; + + &.retention__table__average { + font-weight: 700; + } + } + + &__size { + text-align: center; + padding: 10px; + } + + &__label { + font-weight: 700; + color: $darker-text-color; + } + + &__box { + box-sizing: border-box; + background: $ui-highlight-color; + padding: 10px; + font-weight: 500; + color: $primary-text-color; + width: 52px; + margin: 1px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + } +} + +.sparkline { + display: block; + text-decoration: none; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + padding: 0; + position: relative; + padding-bottom: 55px + 20px; + overflow: hidden; + + &__value { + display: flex; + line-height: 33px; + align-items: flex-end; + padding: 20px; + padding-bottom: 10px; + + &__total { + display: block; + margin-right: 10px; + font-weight: 500; + font-size: 28px; + color: $primary-text-color; + } + + &__change { + display: block; + font-weight: 500; + font-size: 18px; + color: $darker-text-color; + margin-bottom: -3px; + + &.positive { + color: $valid-value-color; + } + + &.negative { + color: $error-value-color; + } + } + } + + &__label { + padding: 0 20px; + padding-bottom: 10px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + } + + &__graph { + position: absolute; + bottom: 0; + + svg { + display: block; + margin: 0; + } + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !important; + } + } +} + +a.sparkline { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 6%); + } +} + +.skeleton { + background-color: lighten($ui-base-color, 8%); + background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%)); + background-size: 200px 100%; + background-repeat: no-repeat; + border-radius: 4px; + display: inline-block; + line-height: 1; + width: 100%; + animation: skeleton 1.2s ease-in-out infinite; +} + +@keyframes skeleton { + 0% { + background-position: -200px 0; + } + + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.dimension { + table { + width: 100%; + } + + &__item { + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__key { + font-weight: 500; + padding: 11px 10px; + } + + &__value { + text-align: right; + color: $darker-text-color; + padding: 11px 10px; + } + + &__indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: $ui-highlight-color; + margin-right: 10px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + + &:last-child { + border-bottom: 0; + } + } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 12bc472f5..c239d4c78 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -6955,7 +6955,6 @@ noscript { &__current { flex: 0 0 auto; font-size: 24px; - line-height: 36px; font-weight: 500; text-align: right; padding-right: 15px; @@ -6977,6 +6976,58 @@ noscript { fill: none !important; } } + + &--requires-review { + .trends__item__name { + color: $gold-star; + + a { + color: $gold-star; + } + } + + .trends__item__current { + color: $gold-star; + } + + .trends__item__sparkline { + path:first-child { + fill: rgba($gold-star, 0.25) !important; + } + + path:last-child { + stroke: lighten($gold-star, 6%) !important; + } + } + } + + &--disabled { + .trends__item__name { + color: lighten($ui-base-color, 12%); + + a { + color: lighten($ui-base-color, 12%); + } + } + + .trends__item__current { + color: lighten($ui-base-color, 12%); + } + + .trends__item__sparkline { + path:first-child { + fill: rgba(lighten($ui-base-color, 12%), 0.25) !important; + } + + path:last-child { + stroke: lighten(lighten($ui-base-color, 12%), 6%) !important; + } + } + } + } + + &--compact &__item { + padding: 10px; } } diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index c0944d417..cad5a105b 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -56,23 +56,56 @@ } } -.dashboard__widgets { - display: flex; - flex-wrap: wrap; - margin: 0 -5px; +.dashboard { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + grid-gap: 10px; - & > div { - flex: 0 0 33.333%; - margin-bottom: 20px; + &__item { + &--span-double-column { + grid-column: span 2; + } - & > div { - padding: 0 5px; + &--span-double-row { + grid-row: span 2; + } + + h4 { + padding-top: 20px; } } - a:not(.name-tag) { - color: $ui-secondary-color; - font-weight: 500; + &__quick-access { + display: flex; + align-items: baseline; + border-radius: 4px; + background: $ui-highlight-color; + color: $primary-text-color; + transition: all 100ms ease-in; + font-size: 14px; + padding: 0 16px; + line-height: 36px; + height: 36px; text-decoration: none; + margin-bottom: 4px; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-highlight-color, 10%); + transition: all 200ms ease-out; + } + + span { + flex: 1 1 auto; + } + + .fa { + flex: 0 0 auto; + } + + strong { + font-weight: 700; + } } } |