diff options
Diffstat (limited to 'app/javascript')
30 files changed, 593 insertions, 413 deletions
diff --git a/app/javascript/flavours/glitch/components/animated_number.jsx b/app/javascript/flavours/glitch/components/animated_number.jsx deleted file mode 100644 index dd21d97f0..000000000 --- a/app/javascript/flavours/glitch/components/animated_number.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ShortNumber from 'mastodon/components/short_number'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; -import { reduceMotion } from 'flavours/glitch/initial_state'; - -const obfuscatedCount = count => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - -export default class AnimatedNumber extends React.PureComponent { - - static propTypes = { - value: PropTypes.number.isRequired, - obfuscate: PropTypes.bool, - }; - - state = { - direction: 1, - }; - - componentWillReceiveProps (nextProps) { - if (nextProps.value > this.props.value) { - this.setState({ direction: 1 }); - } else if (nextProps.value < this.props.value) { - this.setState({ direction: -1 }); - } - } - - willEnter = () => { - const { direction } = this.state; - - return { y: -1 * direction }; - }; - - willLeave = () => { - const { direction } = this.state; - - return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) }; - }; - - render () { - const { value, obfuscate } = this.props; - const { direction } = this.state; - - if (reduceMotion) { - return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />; - } - - const styles = [{ - key: `${value}`, - data: value, - style: { y: spring(0, { damping: 35, stiffness: 400 }) }, - }]; - - return ( - <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> - {items => ( - <span className='animated-number'> - {items.map(({ key, data, style }) => ( - <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span> - ))} - </span> - )} - </TransitionMotion> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/animated_number.tsx b/app/javascript/flavours/glitch/components/animated_number.tsx new file mode 100644 index 000000000..1673ff41b --- /dev/null +++ b/app/javascript/flavours/glitch/components/animated_number.tsx @@ -0,0 +1,58 @@ +import React, { useCallback, useState } from 'react'; +import ShortNumber from './short_number'; +import { TransitionMotion, spring } from 'react-motion'; +import { reduceMotion } from '../initial_state'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +type Props = { + value: number; + obfuscate?: boolean; +} +export const AnimatedNumber: React.FC<Props> = ({ + value, + obfuscate, +})=> { + const [previousValue, setPreviousValue] = useState(value); + const [direction, setDirection] = useState<1|-1>(1); + + if (previousValue !== value) { + setPreviousValue(value); + setDirection(value > previousValue ? 1 : -1); + } + + const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); + const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + <TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}> + {items => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span> + ))} + </span> + )} + </TransitionMotion> + ); +}; + +export default AnimatedNumber; diff --git a/app/javascript/flavours/glitch/components/gifv.jsx b/app/javascript/flavours/glitch/components/gifv.jsx deleted file mode 100644 index 1ce7e7c29..000000000 --- a/app/javascript/flavours/glitch/components/gifv.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default class GIFV extends React.PureComponent { - - static propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string, - lang: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - }; - - state = { - loading: true, - }; - - handleLoadedData = () => { - this.setState({ loading: false }); - }; - - componentWillReceiveProps (nextProps) { - if (nextProps.src !== this.props.src) { - this.setState({ loading: true }); - } - } - - handleClick = e => { - const { onClick } = this.props; - - if (onClick) { - e.stopPropagation(); - onClick(); - } - }; - - render () { - const { src, width, height, alt, lang } = this.props; - const { loading } = this.state; - - return ( - <div className='gifv' style={{ position: 'relative' }}> - {loading && ( - <canvas - width={width} - height={height} - role='button' - tabIndex={0} - aria-label={alt} - title={alt} - lang={lang} - onClick={this.handleClick} - /> - )} - - <video - src={src} - role='button' - tabIndex={0} - aria-label={alt} - title={alt} - lang={lang} - muted - loop - autoPlay - playsInline - onClick={this.handleClick} - onLoadedData={this.handleLoadedData} - style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} - /> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/gifv.tsx b/app/javascript/flavours/glitch/components/gifv.tsx new file mode 100644 index 000000000..8968170c5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/gifv.tsx @@ -0,0 +1,68 @@ +import React, { useCallback, useState } from 'react'; + +type Props = { + src: string; + key: string; + alt?: string; + lang?: string; + width: number; + height: number; + onClick?: () => void; +} + +export const GIFV: React.FC<Props> = ({ + src, + alt, + lang, + width, + height, + onClick, +})=> { + const [loading, setLoading] = useState(true); + + const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleClick: React.MouseEventHandler = useCallback((e) => { + if (onClick) { + e.stopPropagation(); + onClick(); + } + }, [onClick]); + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + onClick={handleClick} + /> + )} + + <video + src={src} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + muted + loop + autoPlay + playsInline + onClick={handleClick} + onLoadedData={handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </div> + ); +}; + +export default GIFV; diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index 63a331086..06984f3ad 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx @@ -203,7 +203,7 @@ class Conversation extends ImmutablePureComponent { parseClick={this.parseClick} expanded={isExpanded} onExpandedToggle={this.handleShowMore} - collapsable + collapsible media={media} /> diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index c220d761f..5d1160039 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -63,6 +63,7 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, + statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}' }, detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, @@ -161,13 +162,14 @@ const truncate = (str, num) => { } }; -const titleFromStatus = status => { +const titleFromStatus = (intl, status) => { const displayName = status.getIn(['account', 'display_name']); const username = status.getIn(['account', 'username']); - const prefix = displayName.trim().length === 0 ? username : displayName; + const user = displayName.trim().length === 0 ? username : displayName; const text = status.get('search_index'); + const attachmentCount = status.get('media_attachments').size; - return `${prefix}: "${truncate(text, 30)}"`; + return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount }); }; class Status extends ImmutablePureComponent { @@ -710,7 +712,7 @@ class Status extends ImmutablePureComponent { </ScrollContainer> <Helmet> - <title>{titleFromStatus(status)}</title> + <title>{titleFromStatus(intl, status)}</title> <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} /> </Helmet> </Column> diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx index 2d49312e5..440a6ac4b 100644 --- a/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx @@ -131,4 +131,4 @@ class FilterModal extends ImmutablePureComponent { } -export default connect(injectIntl(FilterModal)); +export default connect()(injectIntl(FilterModal)); diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx index a5637d31c..78aee8dfe 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx @@ -371,7 +371,7 @@ class FocalPointModal extends ImmutablePureComponent { {focals && ( <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} - {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />} <div className='focal-point__preview'> <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx index fd2bd43cf..6ca96b743 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx @@ -188,7 +188,7 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={width} height={height} - key={image.get('preview_url')} + key={image.get('url')} alt={image.get('description')} lang={language} onClick={this.toggleNavigation} diff --git a/app/javascript/flavours/glitch/packs/public.jsx b/app/javascript/flavours/glitch/packs/public.jsx index 335a0710d..93b249bb4 100644 --- a/app/javascript/flavours/glitch/packs/public.jsx +++ b/app/javascript/flavours/glitch/packs/public.jsx @@ -2,6 +2,15 @@ import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/load_polyfills'; import ready from 'flavours/glitch/ready'; import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions'; +import axios from 'axios'; +import { throttle } from 'lodash'; +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' }, + passwordExceedsLength: { id: 'password_confirmation.exceeds_maxlength', defaultMessage: 'Password confirmation exceeds the maximum password length' }, + passwordDoesNotMatch: { id: 'password_confirmation.mismatching', defaultMessage: 'Password confirmation does not match' }, +}); function main() { const IntlMessageFormat = require('intl-messageformat').default; @@ -9,7 +18,7 @@ function main() { const { delegate } = require('@rails/ujs'); const emojify = require('flavours/glitch/features/emoji/emoji').default; const { getLocale } = require('locales'); - const { messages } = getLocale(); + const { localeData } = getLocale(); const React = require('react'); const ReactDOM = require('react-dom'); const { createBrowserHistory } = require('history'); @@ -54,6 +63,11 @@ function main() { hour12: false, }); + const formatMessage = ({ id, defaultMessage }, values) => { + const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale); + return messageFormat.format(values); + }; + [].forEach.call(document.querySelectorAll('.emojify'), (content) => { content.innerHTML = emojify(content.innerHTML); }); @@ -73,7 +87,7 @@ function main() { date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }; - const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale); + const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale); [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => { const datetime = new Date(content.getAttribute('datetime')); @@ -99,7 +113,7 @@ function main() { const timeGiven = content.getAttribute('datetime').includes('T'); content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); content.textContent = timeAgoString({ - formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values), + formatMessage, formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), }, datetime, now, now.getFullYear(), timeGiven); }); @@ -128,17 +142,19 @@ function main() { scrollToDetailedStatus(); } - delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => { - const password = document.getElementById('registration_user_password'); - const confirmation = document.getElementById('registration_user_password_confirmation'); - if (confirmation.value && confirmation.value.length > password.maxLength) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format()); - } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + delegate(document, '#user_account_attributes_username', 'input', throttle(() => { + const username = document.getElementById('user_account_attributes_username'); + + if (username.value && username.value.length > 0) { + axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => { + username.setCustomValidity(formatMessage(messages.usernameTaken)); + }).catch(() => { + username.setCustomValidity(''); + }); } else { - confirmation.setCustomValidity(''); + username.setCustomValidity(''); } - }); + }, 500, { leading: false, trailing: true })); delegate(document, '#user_password,#user_password_confirmation', 'input', () => { const password = document.getElementById('user_password'); @@ -146,9 +162,9 @@ function main() { if (!confirmation) return; if (confirmation.value && confirmation.value.length > password.maxLength) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format()); + confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength)); } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch)); } else { confirmation.setCustomValidity(''); } @@ -162,10 +178,10 @@ function main() { if (statusEl.dataset.spoiler === 'expanded') { statusEl.dataset.spoiler = 'folded'; - this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format(); + this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format(); } else { statusEl.dataset.spoiler = 'expanded'; - this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format(); + this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format(); } return false; @@ -173,7 +189,7 @@ function main() { [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => { const statusEl = spoilerLink.parentNode.parentNode; - const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more'); + const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more'); spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); }); }); diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index bb44d1bac..f69e8f276 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -1118,3 +1118,89 @@ code { white-space: nowrap; } } + +.progress-tracker { + display: flex; + align-items: center; + padding-bottom: 30px; + margin-bottom: 30px; + + li { + flex: 0 0 auto; + position: relative; + } + + .separator { + height: 2px; + background: $ui-base-lighter-color; + flex: 1 1 auto; + + &.completed { + background: $highlight-text-color; + } + } + + .circle { + box-sizing: border-box; + position: relative; + width: 30px; + height: 30px; + border-radius: 50%; + border: 2px solid $ui-base-lighter-color; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + } + } + + .label { + position: absolute; + font-size: 14px; + font-weight: 500; + color: $secondary-text-color; + padding-top: 10px; + text-align: center; + width: 100px; + left: 50%; + transform: translateX(-50%); + } + + li:first-child .label { + left: auto; + inset-inline-start: 0; + text-align: start; + transform: none; + } + + li:last-child .label { + left: auto; + inset-inline-end: 0; + text-align: end; + transform: none; + } + + .active .circle { + border-color: $highlight-text-color; + + &::before { + content: ''; + width: 10px; + height: 10px; + border-radius: 50%; + background: $highlight-text-color; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + } + + .completed .circle { + border-color: $highlight-text-color; + background: $highlight-text-color; + } +} diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap index f8385357a..fbd44ecc5 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap @@ -3,6 +3,8 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = ` <div className="account__avatar-overlay" + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ { "height": 46, @@ -15,8 +17,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = ` > <div className="account__avatar" - onMouseEnter={[Function]} - onMouseLeave={[Function]} style={ { "height": "36px", @@ -35,8 +35,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = ` > <div className="account__avatar" - onMouseEnter={[Function]} - onMouseLeave={[Function]} style={ { "height": "24px", diff --git a/app/javascript/mastodon/components/animated_number.jsx b/app/javascript/mastodon/components/animated_number.jsx deleted file mode 100644 index ce688f04f..000000000 --- a/app/javascript/mastodon/components/animated_number.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ShortNumber from 'mastodon/components/short_number'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; -import { reduceMotion } from 'mastodon/initial_state'; - -const obfuscatedCount = count => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - -export default class AnimatedNumber extends React.PureComponent { - - static propTypes = { - value: PropTypes.number.isRequired, - obfuscate: PropTypes.bool, - }; - - state = { - direction: 1, - }; - - componentWillReceiveProps (nextProps) { - if (nextProps.value > this.props.value) { - this.setState({ direction: 1 }); - } else if (nextProps.value < this.props.value) { - this.setState({ direction: -1 }); - } - } - - willEnter = () => { - const { direction } = this.state; - - return { y: -1 * direction }; - }; - - willLeave = () => { - const { direction } = this.state; - - return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) }; - }; - - render () { - const { value, obfuscate } = this.props; - const { direction } = this.state; - - if (reduceMotion) { - return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />; - } - - const styles = [{ - key: `${value}`, - data: value, - style: { y: spring(0, { damping: 35, stiffness: 400 }) }, - }]; - - return ( - <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> - {items => ( - <span className='animated-number'> - {items.map(({ key, data, style }) => ( - <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span> - ))} - </span> - )} - </TransitionMotion> - ); - } - -} diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx new file mode 100644 index 000000000..1673ff41b --- /dev/null +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -0,0 +1,58 @@ +import React, { useCallback, useState } from 'react'; +import ShortNumber from './short_number'; +import { TransitionMotion, spring } from 'react-motion'; +import { reduceMotion } from '../initial_state'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +type Props = { + value: number; + obfuscate?: boolean; +} +export const AnimatedNumber: React.FC<Props> = ({ + value, + obfuscate, +})=> { + const [previousValue, setPreviousValue] = useState(value); + const [direction, setDirection] = useState<1|-1>(1); + + if (previousValue !== value) { + setPreviousValue(value); + setDirection(value > previousValue ? 1 : -1); + } + + const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); + const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + <TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}> + {items => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span> + ))} + </span> + )} + </TransitionMotion> + ); +}; + +export default AnimatedNumber; diff --git a/app/javascript/mastodon/components/avatar_overlay.jsx b/app/javascript/mastodon/components/avatar_overlay.jsx deleted file mode 100644 index 034e8ba56..000000000 --- a/app/javascript/mastodon/components/avatar_overlay.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { autoPlayGif } from '../initial_state'; -import Avatar from './avatar'; - -export default class AvatarOverlay extends React.PureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - friend: ImmutablePropTypes.map.isRequired, - animate: PropTypes.bool, - size: PropTypes.number, - baseSize: PropTypes.number, - overlaySize: PropTypes.number, - }; - - static defaultProps = { - animate: autoPlayGif, - size: 46, - baseSize: 36, - overlaySize: 24, - }; - - state = { - hovering: false, - }; - - handleMouseEnter = () => { - if (this.props.animate) return; - this.setState({ hovering: true }); - }; - - handleMouseLeave = () => { - if (this.props.animate) return; - this.setState({ hovering: false }); - }; - - render() { - const { account, friend, animate, size, baseSize, overlaySize } = this.props; - const { hovering } = this.state; - - return ( - <div className='account__avatar-overlay' style={{ width: size, height: size }}> - <div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div> - <div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div> - </div> - ); - } - -} diff --git a/app/javascript/mastodon/components/avatar_overlay.tsx b/app/javascript/mastodon/components/avatar_overlay.tsx new file mode 100644 index 000000000..5c65a928c --- /dev/null +++ b/app/javascript/mastodon/components/avatar_overlay.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import type { Account } from '../../types/resources'; +import { useHovering } from '../../hooks/useHovering'; +import { autoPlayGif } from '../initial_state'; + +type Props = { + account: Account; + friend: Account; + size?: number; + baseSize?: number; + overlaySize?: number; +}; + +export const AvatarOverlay: React.FC<Props> = ({ + account, + friend, + size = 46, + baseSize = 36, + overlaySize = 24, +}) => { + const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(autoPlayGif); + const accountSrc = hovering ? account?.get('avatar') : account?.get('avatar_static'); + const friendSrc = hovering ? friend?.get('avatar') : friend?.get('avatar_static'); + + return ( + <div + className='account__avatar-overlay' style={{ width: size, height: size }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + <div className='account__avatar-overlay-base'> + <div + className='account__avatar' + style={{ width: `${baseSize}px`, height: `${baseSize}px` }} + > + {accountSrc && <img src={accountSrc} alt={account?.get('acct')} />} + </div> + </div> + <div className='account__avatar-overlay-overlay'> + <div + className='account__avatar' + style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} + > + {friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />} + </div> + </div> + </div> + ); +}; + +export default AvatarOverlay; diff --git a/app/javascript/mastodon/components/gifv.jsx b/app/javascript/mastodon/components/gifv.jsx deleted file mode 100644 index 1ce7e7c29..000000000 --- a/app/javascript/mastodon/components/gifv.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default class GIFV extends React.PureComponent { - - static propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string, - lang: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - }; - - state = { - loading: true, - }; - - handleLoadedData = () => { - this.setState({ loading: false }); - }; - - componentWillReceiveProps (nextProps) { - if (nextProps.src !== this.props.src) { - this.setState({ loading: true }); - } - } - - handleClick = e => { - const { onClick } = this.props; - - if (onClick) { - e.stopPropagation(); - onClick(); - } - }; - - render () { - const { src, width, height, alt, lang } = this.props; - const { loading } = this.state; - - return ( - <div className='gifv' style={{ position: 'relative' }}> - {loading && ( - <canvas - width={width} - height={height} - role='button' - tabIndex={0} - aria-label={alt} - title={alt} - lang={lang} - onClick={this.handleClick} - /> - )} - - <video - src={src} - role='button' - tabIndex={0} - aria-label={alt} - title={alt} - lang={lang} - muted - loop - autoPlay - playsInline - onClick={this.handleClick} - onLoadedData={this.handleLoadedData} - style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} - /> - </div> - ); - } - -} diff --git a/app/javascript/mastodon/components/gifv.tsx b/app/javascript/mastodon/components/gifv.tsx new file mode 100644 index 000000000..8968170c5 --- /dev/null +++ b/app/javascript/mastodon/components/gifv.tsx @@ -0,0 +1,68 @@ +import React, { useCallback, useState } from 'react'; + +type Props = { + src: string; + key: string; + alt?: string; + lang?: string; + width: number; + height: number; + onClick?: () => void; +} + +export const GIFV: React.FC<Props> = ({ + src, + alt, + lang, + width, + height, + onClick, +})=> { + const [loading, setLoading] = useState(true); + + const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleClick: React.MouseEventHandler = useCallback((e) => { + if (onClick) { + e.stopPropagation(); + onClick(); + } + }, [onClick]); + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + onClick={handleClick} + /> + )} + + <video + src={src} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + muted + loop + autoPlay + playsInline + onClick={handleClick} + onLoadedData={handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </div> + ); +}; + +export default GIFV; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 923dc892d..60a77a39c 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -541,7 +541,7 @@ class Status extends ImmutablePureComponent { expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} onTranslate={this.handleTranslate} - collapsable + collapsible onCollapsedToggle={this.handleCollapsedToggle} /> diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index fb953b9dd..60f820bc5 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -65,7 +65,7 @@ class StatusContent extends React.PureComponent { onExpandedToggle: PropTypes.func, onTranslate: PropTypes.func, onClick: PropTypes.func, - collapsable: PropTypes.bool, + collapsible: PropTypes.bool, onCollapsedToggle: PropTypes.func, languages: ImmutablePropTypes.map, intl: PropTypes.object, @@ -112,10 +112,10 @@ class StatusContent extends React.PureComponent { } if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsable, onClick } = this.props; + const { collapsible, onClick } = this.props; const collapsed = - collapsable + collapsible && onClick && node.clientHeight > MAX_HEIGHT && status.get('spoiler_text').length === 0; diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index d0dbffe65..11f2790bf 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -165,7 +165,7 @@ class Conversation extends ImmutablePureComponent { onClick={this.handleClick} expanded={!lastStatus.get('hidden')} onExpandedToggle={this.handleShowMore} - collapsable + collapsible /> {lastStatus.get('media_attachments').size > 0 && ( diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index b547741f7..900b19c31 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -69,6 +69,7 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, + statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}' }, detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, @@ -166,13 +167,14 @@ const truncate = (str, num) => { } }; -const titleFromStatus = status => { +const titleFromStatus = (intl, status) => { const displayName = status.getIn(['account', 'display_name']); const username = status.getIn(['account', 'username']); - const prefix = displayName.trim().length === 0 ? username : displayName; + const user = displayName.trim().length === 0 ? username : displayName; const text = status.get('search_index'); + const attachmentCount = status.get('media_attachments').size; - return `${prefix}: "${truncate(text, 30)}"`; + return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount }); }; class Status extends ImmutablePureComponent { @@ -670,7 +672,7 @@ class Status extends ImmutablePureComponent { </ScrollContainer> <Helmet> - <title>{titleFromStatus(status)}</title> + <title>{titleFromStatus(intl, status)}</title> <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} /> </Helmet> </Column> diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.jsx b/app/javascript/mastodon/features/ui/components/filter_modal.jsx index 32ebaf7b7..8d77fb3df 100644 --- a/app/javascript/mastodon/features/ui/components/filter_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/filter_modal.jsx @@ -131,4 +131,4 @@ class FilterModal extends ImmutablePureComponent { } -export default connect(injectIntl(FilterModal)); +export default connect()(injectIntl(FilterModal)); diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx index 11c4c5237..2a1e4c8bb 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx @@ -383,7 +383,7 @@ class FocalPointModal extends ImmutablePureComponent { {focals && ( <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} - {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />} <div className='focal-point__preview'> <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx index 52bd75453..ec6ddc0e1 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx @@ -186,7 +186,7 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={width} height={height} - key={image.get('preview_url')} + key={image.get('url')} alt={image.get('description')} lang={language} onClick={this.toggleNavigation} diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 1351945eb..6d6683808 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -3733,6 +3733,10 @@ "id": "status.show_less_all" }, { + "defaultMessage": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}", + "id": "status.title.with_attachments" + }, + { "defaultMessage": "Detailed conversation view", "id": "status.detailed_status" }, @@ -4354,5 +4358,22 @@ } ], "path": "app/javascript/mastodon/features/video/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "That username is taken. Try another", + "id": "username.taken" + }, + { + "defaultMessage": "Password confirmation exceeds the maximum password length", + "id": "password_confirmation.exceeds_maxlength" + }, + { + "defaultMessage": "Password confirmation does not match", + "id": "password_confirmation.mismatching" + } + ], + "path": "app/javascript/packs/public.json" } ] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ae2d5a999..31fa3ca3a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -443,6 +443,8 @@ "notifications_permission_banner.enable": "Enable desktop notifications", "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", "notifications_permission_banner.title": "Never miss a thing", + "password_confirmation.exceeds_maxlength": "Password confirmation exceeds the maximum password length", + "password_confirmation.mismatching": "Password confirmation does not match", "picture_in_picture.restore": "Put it back", "poll.closed": "Closed", "poll.refresh": "Refresh", @@ -598,6 +600,7 @@ "status.show_more": "Show more", "status.show_more_all": "Show more for all", "status.show_original": "Show original", + "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}", "status.translate": "Translate", "status.translated_from_with": "Translated from {lang} using {provider}", "status.uncached_media_warning": "Not available", @@ -650,6 +653,7 @@ "upload_modal.preview_label": "Preview ({ratio})", "upload_progress.label": "Uploading...", "upload_progress.processing": "Processing…", + "username.taken": "That username is taken. Try another", "video.close": "Close video", "video.download": "Download file", "video.exit_fullscreen": "Exit full screen", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 0085563d2..886976823 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -597,6 +597,7 @@ "status.show_more": "Mostrar más", "status.show_more_all": "Mostrar más para todo", "status.show_original": "Mostrar original", + "status.title.with_attachments": "{user} publicó {attachmentCount, plural, one {un archivo adjunto} other {{attachmentCount} archivos adjuntos}}", "status.translate": "Traducir", "status.translated_from_with": "Traducido del {lang} usando {provider}", "status.uncached_media_warning": "No disponible", diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx index ad6bf237f..606ddc3bf 100644 --- a/app/javascript/packs/public.jsx +++ b/app/javascript/packs/public.jsx @@ -4,6 +4,15 @@ import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; import 'cocoon-js-vanilla'; +import axios from 'axios'; +import { throttle } from 'lodash'; +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' }, + passwordExceedsLength: { id: 'password_confirmation.exceeds_maxlength', defaultMessage: 'Password confirmation exceeds the maximum password length' }, + passwordDoesNotMatch: { id: 'password_confirmation.mismatching', defaultMessage: 'Password confirmation does not match' }, +}); start(); @@ -13,7 +22,7 @@ function main() { const { delegate } = require('@rails/ujs'); const emojify = require('../mastodon/features/emoji/emoji').default; const { getLocale } = require('../mastodon/locales'); - const { messages } = getLocale(); + const { localeData } = getLocale(); const React = require('react'); const ReactDOM = require('react-dom'); const { createBrowserHistory } = require('history'); @@ -58,6 +67,11 @@ function main() { hour12: false, }); + const formatMessage = ({ id, defaultMessage }, values) => { + const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale); + return messageFormat.format(values); + }; + [].forEach.call(document.querySelectorAll('.emojify'), (content) => { content.innerHTML = emojify(content.innerHTML); }); @@ -77,7 +91,7 @@ function main() { date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }; - const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale); + const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale); [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => { const datetime = new Date(content.getAttribute('datetime')); @@ -103,7 +117,7 @@ function main() { const timeGiven = content.getAttribute('datetime').includes('T'); content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); content.textContent = timeAgoString({ - formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values), + formatMessage, formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), }, datetime, now, now.getFullYear(), timeGiven); }); @@ -133,17 +147,19 @@ function main() { scrollToDetailedStatus(); } - delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => { - const password = document.getElementById('registration_user_password'); - const confirmation = document.getElementById('registration_user_password_confirmation'); - if (confirmation.value && confirmation.value.length > password.maxLength) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format()); - } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + delegate(document, '#user_account_attributes_username', 'input', throttle(() => { + const username = document.getElementById('user_account_attributes_username'); + + if (username.value && username.value.length > 0) { + axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => { + username.setCustomValidity(formatMessage(messages.usernameTaken)); + }).catch(() => { + username.setCustomValidity(''); + }); } else { - confirmation.setCustomValidity(''); + username.setCustomValidity(''); } - }); + }, 500, { leading: false, trailing: true })); delegate(document, '#user_password,#user_password_confirmation', 'input', () => { const password = document.getElementById('user_password'); @@ -151,9 +167,9 @@ function main() { if (!confirmation) return; if (confirmation.value && confirmation.value.length > password.maxLength) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format()); + confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength)); } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch)); } else { confirmation.setCustomValidity(''); } @@ -167,10 +183,10 @@ function main() { if (statusEl.dataset.spoiler === 'expanded') { statusEl.dataset.spoiler = 'folded'; - this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format(); + this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format(); } else { statusEl.dataset.spoiler = 'expanded'; - this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format(); + this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format(); } return false; @@ -178,7 +194,7 @@ function main() { [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => { const statusEl = spoilerLink.parentNode.parentNode; - const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more'); + const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more'); spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); }); }); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 7d4bde5e9..dc52bcd87 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1112,3 +1112,89 @@ code { white-space: nowrap; } } + +.progress-tracker { + display: flex; + align-items: center; + padding-bottom: 30px; + margin-bottom: 30px; + + li { + flex: 0 0 auto; + position: relative; + } + + .separator { + height: 2px; + background: $ui-base-lighter-color; + flex: 1 1 auto; + + &.completed { + background: $highlight-text-color; + } + } + + .circle { + box-sizing: border-box; + position: relative; + width: 30px; + height: 30px; + border-radius: 50%; + border: 2px solid $ui-base-lighter-color; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + } + } + + .label { + position: absolute; + font-size: 14px; + font-weight: 500; + color: $secondary-text-color; + padding-top: 10px; + text-align: center; + width: 100px; + left: 50%; + transform: translateX(-50%); + } + + li:first-child .label { + left: auto; + inset-inline-start: 0; + text-align: start; + transform: none; + } + + li:last-child .label { + left: auto; + inset-inline-end: 0; + text-align: end; + transform: none; + } + + .active .circle { + border-color: $highlight-text-color; + + &::before { + content: ''; + width: 10px; + height: 10px; + border-radius: 50%; + background: $highlight-text-color; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + } + + .completed .circle { + border-color: $highlight-text-color; + background: $highlight-text-color; + } +} |