diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js index 8817c09b607..d2db89ca77f 100644 --- a/app/javascript/core/admin.js +++ b/app/javascript/core/admin.js @@ -101,24 +101,4 @@ ready(() => { const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); if (registrationMode) onChangeRegistrationMode(registrationMode); - - 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(( - - - - ), element); - }); - }).catch(error => { - console.error(error); - }); - }); }); 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 00000000000..39ef216bd14 --- /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 = ( + + + + + ); + } else { + const measure = data[0]; + const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1); + + content = ( + + + 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'} + + ); + } + + const inner = ( + +
+ {content} +
+ +
+ {label} +
+ +
+ {!loading && ( + x.value * 1)}> + + + )} +
+
+ ); + + if (href) { + return ( + + {inner} + + ); + } else { + return ( +
+ {inner} +
+ ); + } + } + +} 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 00000000000..b4fbf86c834 --- /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 = ( + + + {Array.from(Array(limit)).map((_, i) => ( + + + + + + ))} + +
+ + + +
+ ); + } else { + const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0); + + content = ( + + + {data[0].data.map(item => ( + + + + + + ))} + +
+ + {item.human_key} + + {typeof item.human_value !== 'undefined' ? item.human_value : } +
+ ); + } + + return ( +
+

{label}

+ + {content} +
+ ); + } + +} 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 00000000000..8295362a445 --- /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 ; + default: + return ; + } +}; + +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 = ; + } else { + content = ( + + + + + + + + {data[0].data.slice(1).map((retention, i) => ( + + ))} + + + + + + + + {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 ( + + ); + })} + + + + + {data.slice(0, -1).map(cohort => ( + + + + + + {cohort.data.slice(1).map(retention => ( + + ))} + + ))} + +
+
+ +
+
+
+ +
+
+
+ {i + 1} +
+
+
+ +
+
+
+ sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> +
+
+
+ +
+
+
+ {dateForCohort(cohort)} +
+
+
+ +
+
+
+ +
+
+ ); + } + + return ( +
+

+ + {content} +
+ ); + } + +} 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 00000000000..d7c4eb72c38 --- /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 = ( +
+ {Array.from(Array(limit)).map((_, i) => ( + + ))} +
+ ); + } else { + content = ( +
+ {data.map(hashtag => ( + day.uses)} + className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')} + /> + ))} +
+ ); + } + + return ( +
+

+ + {content} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js index d00c01e779b..769185a2b5b 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 }) => ( -
+export const ImmutableHashtag = ({ hashtag }) => ( + day.get('uses')).toArray()} + /> +); + +ImmutableHashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +const Hashtag = ({ name, href, to, people, uses, history, className }) => ( +
- - #{hashtag.get('name')} + + {name ? #{name} : } - + {typeof people !== 'undefined' ? : }
- + {typeof uses !== 'undefined' ? : }
- day.get('uses')) - .toArray()} - > + 0)}> @@ -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 00000000000..09093e99c75 --- /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 }) => ; + +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 00000000000..64dabac8ba9 --- /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 ( + + {children} + + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index 8657b8064af..1ddbc706b3b 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 9a76e541811..cbc1f35e52f 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 0734ec72b96..ce4d94c647c 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 00000000000..4c09ddb05c8 --- /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(( + + + + ), 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 4801a464401..24618c29f2a 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 929769130e9..f7415368b82 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 c0944d417dd..cad5a105be7 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 2a98e4c29b8..ee2b699b2d4 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 6f2505cae87..6ef563ad8f9 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 74e9fb1b5f3..3263fd7d4fe 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/packs/admin.js b/app/javascript/packs/admin.js new file mode 100644 index 00000000000..59901500011 --- /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(( + + + + ), element); + }); + }).catch(error => { + console.error(error); + }); + }); +});