#379: split languageSwitcher to smaller components

This commit is contained in:
SleepWalker 2017-12-30 23:38:54 +02:00
parent b19eeb9e94
commit 7dbd569d45
14 changed files with 330 additions and 253 deletions

View File

@ -109,7 +109,7 @@ function sortByKeys(object) {
* @return {string}
*/
function trimValueInBrackets(value) {
return value.match(/^([^\(]+)/)[0].trim();
return value.match(/^([^(]+)/)[0].trim();
}
async function pullReadyLanguages() {
@ -141,6 +141,7 @@ async function pull() {
const mapFileContent = {};
langs.map((elem) => {
mapFileContent[elem.locale] = {
code: elem.locale,
name: ORIGINAL_NAMES_MAP[elem.locale] || trimValueInBrackets(elem.local_name),
englishName: ENGLISH_NAMES_MAP[elem.locale] || trimValueInBrackets(elem.english_name),
progress: parseFloat(elem.translation_progress),

View File

@ -1,8 +1,11 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import ContactForm from 'components/contact/ContactForm';
import LanguageSwitcher from 'components/languageSwitcher';
import { create as createPopup } from 'components/ui/popup/actions';
import styles from './footerMenu.scss';
import messages from './footerMenu.intl.json';
@ -47,11 +50,6 @@ class FooterMenu extends Component {
};
}
import { connect } from 'react-redux';
import ContactForm from 'components/contact/ContactForm';
import LanguageSwitcher from 'components/languageSwitcher/LanguageSwitcher';
import { create as createPopup } from 'components/ui/popup/actions';
// mark this component, as not pure, because it is stateless,
// but should be re-rendered, if current lang was changed
export default connect(null, {

View File

@ -0,0 +1,148 @@
// @flow
import React from 'react';
import { TransitionMotion, spring, presets } from 'react-motion';
import { FormattedMessage as Message } from 'react-intl';
import classNames from 'classnames';
import LocaleItem from './LocaleItem';
import messages from './languageSwitcher.intl.json';
import styles from './languageSwitcher.scss';
import thatFuckingPumpkin from './images/that_fucking_pumpkin.svg';
import mayTheForceBeWithYou from './images/may_the_force_be_with_you.svg';
import biteMyShinyMetalAss from './images/bite_my_shiny_metal_ass.svg';
import iTookAnArrowInMyKnee from './images/i_took_an_arrow_in_my_knee.svg';
import type { LocalesMap } from './LanguageSwitcher';
const itemHeight = 51;
export default class LanguageList extends React.Component<{
userLang: string,
langs: LocalesMap,
onChangeLang: (lang: string) => void,
}> {
emptyListStateInner: ?HTMLDivElement;
render() {
const { userLang, langs } = this.props;
const isListEmpty = Object.keys(langs).length === 0;
const firstLocale = Object.keys(langs)[0] || null;
const emptyCaption = this.getEmptyCaption();
return (
<TransitionMotion
defaultStyles={this.getItemsWithDefaultStyles()}
styles={this.getItemsWithStyles()}
willLeave={this.willLeave}
willEnter={this.willEnter}
>
{(items) => (
<div className={styles.languagesList}>
<div
className={classNames(styles.emptyLanguagesListWrapper, {
[styles.emptyLanguagesListVisible]: isListEmpty,
})}
style={{
height: isListEmpty && this.emptyListStateInner ? this.emptyListStateInner.clientHeight : 0,
}}
>
<div
ref={(elem: ?HTMLDivElement) => this.emptyListStateInner = elem}
className={styles.emptyLanguagesList}
>
<img
src={emptyCaption.src}
alt={emptyCaption.caption}
className={styles.emptyLanguagesListCaption}
/>
<div className={styles.emptyLanguagesListSubtitle}>
<Message {...messages.weDoNotSupportThisLang} />
</div>
</div>
</div>
{items.map(({key: locale, data: definition, style}) => (
<div
key={locale}
style={style}
className={classNames(styles.languageItem, {
[styles.activeLanguageItem]: locale === userLang,
[styles.firstLanguageItem]: locale === firstLocale,
})}
onClick={this.onChangeLang(locale)}
>
<LocaleItem locale={definition} />
</div>
))}
</div>
)}
</TransitionMotion>
);
}
getEmptyCaption() {
const emptyCaptions = [
{
// Homestuck
src: thatFuckingPumpkin,
caption: 'That fucking pumpkin',
},
{
// Star Wars
src: mayTheForceBeWithYou,
caption: 'May The Force Be With You',
},
{
// Futurama
src: biteMyShinyMetalAss,
caption: 'Bite my shiny metal ass',
},
{
// The Elder Scrolls V: Skyrim
src: iTookAnArrowInMyKnee,
caption: 'I took an arrow in my knee',
},
];
return emptyCaptions[Math.floor(Math.random() * emptyCaptions.length)];
}
onChangeLang(lang: string) {
return (event: SyntheticMouseEvent<HTMLElement>) => {
event.preventDefault();
this.props.onChangeLang(lang);
};
}
getItemsWithDefaultStyles = () => this.getItemsWithStyles({ useSpring: false });
getItemsWithStyles = ({ useSpring }: { useSpring?: bool } = { useSpring: true }) =>
Object.keys({...this.props.langs})
.reduce((previous, key) => [
...previous,
{
key,
data: this.props.langs[key],
style: {
height: useSpring ? spring(itemHeight, presets.gentle) : itemHeight,
opacity: useSpring ? spring(1, presets.gentle) : 1,
},
},
], []);
willEnter() {
return {
height: 0,
opacity: 1,
};
}
willLeave() {
return {
height: spring(0),
opacity: spring(0),
};
}
}

View File

@ -1,46 +1,41 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { TransitionMotion, spring, presets } from 'react-motion';
import { FormattedMessage as Message, intlShape } from 'react-intl';
import classNames from 'classnames';
import { localeFlags } from 'components/i18n';
import LANGS from 'i18n/index.json';
import formStyles from 'components/ui/form/form.scss';
import popupStyles from 'components/ui/popup/popup.scss';
import icons from 'components/ui/icons.scss';
import styles from './languageSwitcher.scss';
import messages from './languageSwitcher.intl.json';
import thatFuckingPumpkin from './images/that_fucking_pumpkin.svg';
import mayTheForceBeWithYou from './images/may_the_force_be_with_you.svg';
import biteMyShinyMetalAss from './images/bite_my_shiny_metal_ass.svg';
import iTookAnArrowInMyKnee from './images/i_took_an_arrow_in_my_knee.svg';
import LanguageList from './LanguageList';
const improveTranslationUrl = 'http://ely.by/erickskrauch/posts/174943';
const itemHeight = 51;
class LanguageSwitcher extends Component {
static displayName = 'LanguageSwitcher';
export type LocaleData = {
code: string,
name: string,
englishName: string,
progress: number,
isReleased: bool,
};
static propTypes = {
onClose: PropTypes.func,
userLang: PropTypes.string,
changeLang: PropTypes.func,
langs: PropTypes.objectOf(PropTypes.shape({
name: PropTypes.string.isRequired,
englishName: PropTypes.string.isRequired,
progress: PropTypes.number.isRequired,
isReleased: PropTypes.bool.isRequired,
})).isRequired,
emptyCaptions: PropTypes.arrayOf(PropTypes.shape({
src: PropTypes.string.isRequired,
caption: PropTypes.string.isRequired,
})).isRequired,
};
export type LocalesMap = {[code: string]: LocaleData};
class LanguageSwitcher extends Component<{
onClose: Function,
userLang: string,
changeLang: (lang: string) => void,
langs: LocalesMap,
emptyCaptions: Array<{
src: string,
caption: string,
}>,
}, {
filter: string,
filteredLangs: LocalesMap,
}> {
static contextTypes = {
intl: intlShape.isRequired,
};
@ -53,35 +48,11 @@ class LanguageSwitcher extends Component {
static defaultProps = {
langs: LANGS,
onClose() {},
emptyCaptions: [
{
// Homestuck
src: thatFuckingPumpkin,
caption: 'That fucking pumpkin',
},
{
// Star Wars
src: mayTheForceBeWithYou,
caption: 'May The Force Be With You',
},
{
// Futurama
src: biteMyShinyMetalAss,
caption: 'Bite my shiny metal ass',
},
{
// The Elder Scrolls V: Skyrim
src: iTookAnArrowInMyKnee,
caption: 'I took an arrow in my knee',
},
],
};
render() {
const {userLang, onClose, emptyCaptions} = this.props;
const isListEmpty = Object.keys(this.state.filteredLangs).length === 0;
const firstLocale = Object.keys(this.state.filteredLangs)[0] || null;
const emptyCaption = emptyCaptions[Math.floor(Math.random() * emptyCaptions.length)];
const { userLang, onClose } = this.props;
const { filteredLangs } = this.state;
return (
<div className={styles.languageSwitcher}>
@ -108,53 +79,11 @@ class LanguageSwitcher extends Component {
<span className={styles.searchIcon} />
</div>
<TransitionMotion
defaultStyles={this.getItemsWithDefaultStyles()}
styles={this.getItemsWithStyles()}
willLeave={this.willLeave}
willEnter={this.willEnter}
>
{(items) => (
<div className={styles.languagesList}>
<div
className={classNames(styles.emptyLanguagesListWrapper, {
[styles.emptyLanguagesListVisible]: isListEmpty,
})}
style={{
height: isListEmpty ? this.emptyListStateInner.clientHeight : 0,
}}
>
<div
ref={(elem) => this.emptyListStateInner = elem}
className={styles.emptyLanguagesList}
>
<img
src={emptyCaption.src}
alt={emptyCaption.caption}
className={styles.emptyLanguagesListCaption}
/>
<div className={styles.emptyLanguagesListSubtitle}>
<Message {...messages.weDoNotSupportThisLang} />
</div>
</div>
</div>
{items.map(({key: locale, data: definition, style}) => (
<li
key={locale}
style={style}
className={classNames(styles.languageItem, {
[styles.activeLanguageItem]: locale === userLang,
[styles.firstLanguageItem]: locale === firstLocale,
})}
onClick={this.onChangeLang(locale)}
>
{this.renderLanguageItem(locale, definition)}
</li>
))}
</div>
)}
</TransitionMotion>
<LanguageList
userLang={userLang}
langs={filteredLangs}
onChangeLang={this.onChangeLang}
/>
<div className={styles.improveTranslates}>
<div className={styles.improveTranslatesIcon} />
@ -179,57 +108,21 @@ class LanguageSwitcher extends Component {
);
}
renderLanguageItem(locale, localeData) {
const {name, progress, isReleased} = localeData;
let progressLabel;
if (progress !== 100) {
progressLabel = (
<Message {...messages.translationProgress} values={{
progress,
}} />
);
} else if (!isReleased) {
progressLabel = (
<Message {...messages.mayBeInaccurate} />
);
}
return (
<div className={styles.languageFlex}>
<div className={styles.languageIco} style={{
backgroundImage: `url('${localeFlags.getIconUrl(locale)}')`,
}} />
<div className={styles.languageCaptions}>
<div className={styles.languageName}>
{name}
</div>
<div className={styles.languageSubName}>
{localeData.englishName} {progressLabel ? '| ' : ''} {progressLabel}
</div>
</div>
<span className={styles.languageCircle} />
</div>
);
}
onChangeLang(lang) {
return (event) => {
event.preventDefault();
this.changeLang(lang);
};
}
onChangeLang = this.changeLang.bind(this);
changeLang(lang) {
this.props.changeLang(lang);
setTimeout(this.props.onClose, 300);
}
onFilterUpdate = (event) => {
const filter = event.target.value.trim().toLowerCase();
onFilterUpdate = (event: SyntheticInputEvent<HTMLInputElement>) => {
const filter = event.currentTarget.value.trim().toLowerCase();
const { langs } = this.props;
const result = Object.keys(langs).reduce((previous, key) => {
if (langs[key].englishName.toLowerCase().search(filter) === -1
&& langs[key].name.toLowerCase().search(filter) === -1
&& langs[key].name.toLowerCase().search(filter) === -1
) {
return previous;
}
@ -246,12 +139,13 @@ class LanguageSwitcher extends Component {
};
onFilterKeyPress() {
return (event) => {
return (event: SyntheticInputEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || this.state.filter === '') {
return;
}
const locales = Object.keys(this.state.filteredLangs);
if (locales.length === 0) {
return;
}
@ -259,46 +153,6 @@ class LanguageSwitcher extends Component {
this.changeLang(locales[0]);
};
}
getItemsWithDefaultStyles = () => Object.keys(this.props.langs).reduce((previous, key) => {
return [
...previous,
{
key,
data: this.props.langs[key],
style: {
height: itemHeight,
opacity: 1,
},
},
];
}, {});
getItemsWithStyles = () => Object.keys({...this.state.filteredLangs}).reduce((previous, key) => [
...previous,
{
key,
data: this.props.langs[key],
style: {
height: spring(itemHeight, presets.gentle),
opacity: spring(1, presets.gentle),
},
},
], []);
willEnter() {
return {
height: 0,
opacity: 1,
};
}
willLeave() {
return {
height: spring(0),
opacity: spring(0),
};
}
}
import { connect } from 'react-redux';

View File

@ -0,0 +1,47 @@
// @flow
import React from 'react';
import { localeFlags } from 'components/i18n';
import { FormattedMessage as Message } from 'react-intl';
import messages from './languageSwitcher.intl.json';
import styles from './languageSwitcher.scss';
import type { LocaleData } from './LanguageSwitcher';
export default function LocaleItem({
locale,
}: {
locale: LocaleData,
}) {
const {code, name, englishName, progress, isReleased} = locale;
let progressLabel;
if (progress !== 100) {
progressLabel = (
<Message {...messages.translationProgress} values={{
progress,
}} />
);
} else if (!isReleased) {
progressLabel = (
<Message {...messages.mayBeInaccurate} />
);
}
return (
<div className={styles.languageFlex}>
<div className={styles.languageIco} style={{
backgroundImage: `url('${localeFlags.getIconUrl(code)}')`,
}} />
<div className={styles.languageCaptions}>
<div className={styles.languageName}>
{name}
</div>
<div className={styles.languageSubName}>
{englishName} {progressLabel ? '| ' : ''} {progressLabel}
</div>
</div>
<span className={styles.languageCircle} />
</div>
);
}

View File

@ -0,0 +1,31 @@
// @flow
import React from 'react';
import { connect } from 'react-redux';
import { create as createPopup } from 'components/ui/popup/actions';
import { localeFlags } from 'components/i18n';
import LANGS from 'i18n/index.json';
import LanguageSwitcher from '../LanguageSwitcher';
import styles from './link.scss';
function LanguageLink({
userLang,
showLanguageSwitcherPopup,
}: {
userLang: string,
showLanguageSwitcherPopup: Function,
}) {
return (
<span className={styles.languageLink} onClick={showLanguageSwitcherPopup}>
<span className={styles.languageIcon} style={{
backgroundImage: `url('${localeFlags.getIconUrl(userLang)}')`
}} />
{LANGS[userLang].name}
</span>
);
}
export default connect((state) => ({
userLang: state.user.lang,
}), {
showLanguageSwitcherPopup: () => createPopup(LanguageSwitcher),
})(LanguageLink);

View File

@ -0,0 +1,21 @@
.languageLink {
color: #666;
border-bottom: 1px dotted #666;
text-decoration: none;
transition: .25s;
cursor: pointer;
}
.languageIcon {
$height: 13px;
position: relative;
top: 1px;
display: inline-block;
margin-right: 4px;
height: $height;
width: $height * 4 / 3;
box-shadow: 0 0 1px rgba(#000, .2);
background-size: cover;
}

View File

@ -0,0 +1,3 @@
// @flow
export { default } from './LanguageSwitcher';
export { default as ChangeLanguageLink } from './changeLanguageLink/ChangeLanguageLink';

View File

@ -76,6 +76,7 @@ $languageListBorderStyle: 1px solid $languageListBorderColor;
transition: background-color .25s;
cursor: pointer;
overflow: hidden;
line-height: 1;
&:hover {
background-color: $whiteButtonLight;

View File

@ -1,28 +1,22 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage as Message, FormattedRelative as Relative } from 'react-intl';
import { Link } from 'react-router-dom';
import Helmet from 'react-helmet';
import { localeFlags } from 'components/i18n';
import LANGS from 'i18n/index.json';
import { userShape } from 'components/user/User';
import { ChangeLanguageLink } from 'components/languageSwitcher';
import ProfileField from './ProfileField';
import styles from './profile.scss';
import profileForm from './profileForm.scss';
import messages from './Profile.intl.json';
import RulesPage from 'pages/rules/RulesPage';
class Profile extends Component {
static displayName = 'Profile';
static propTypes = {
user: userShape,
createLanguageSwitcherPopup: PropTypes.func.isRequired,
};
import type { User } from 'components/user';
class Profile extends Component<{
user: User
}> {
UUID: ?HTMLDivElement;
render() {
const { user } = this.props;
@ -30,7 +24,7 @@ class Profile extends Component {
return (
<div>
<Message {...messages.accountPreferencesTitle}>
{(pageTitle) => (
{(pageTitle: string) => (
<h2 className={styles.indexTitle}>
<Helmet title={pageTitle} />
{pageTitle}
@ -90,14 +84,7 @@ class Profile extends Component {
<ProfileField
label={<Message {...messages.siteLanguage} />}
value={
<span className={styles.language} onClick={this.onLanguageSwitcher.bind(this)}>
<span className={styles.languageIcon} style={{
backgroundImage: `url('${localeFlags.getIconUrl(user.lang)}')`
}} />
{LANGS[user.lang].name}
</span>
}
value={<ChangeLanguageLink />}
/>
<ProfileField
@ -129,15 +116,17 @@ class Profile extends Component {
);
}
onLanguageSwitcher() {
this.props.createLanguageSwitcherPopup();
}
handleUUIDMouseOver() {
if (!this.UUID) {
return;
}
const el = this.UUID;
try {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.UUID);
range.selectNodeContents(el);
selection.removeAllRanges();
selection.addRange(range);
} catch (err) {
@ -145,17 +134,11 @@ class Profile extends Component {
}
}
setUUID(el) {
setUUID(el: ?HTMLDivElement) {
this.UUID = el;
}
}
import { connect } from 'react-redux';
import LanguageSwitcher from 'components/languageSwitcher/LanguageSwitcher';
import { create as createPopup } from 'components/ui/popup/actions';
export default connect((state) => ({
user: state.user,
}), {
createLanguageSwitcherPopup: () => createPopup(LanguageSwitcher),
})(Profile);
}))(Profile);

View File

@ -68,28 +68,6 @@ $formColumnWidth: 416px;
flex-grow: 1;
}
.language {
color: #666;
border-bottom: 1px dotted #666;
text-decoration: none;
transition: .25s;
cursor: pointer;
}
.languageIcon {
$height: 13px;
position: relative;
top: 1px;
display: inline-block;
margin-right: 4px;
height: $height;
width: $height * 4 / 3;
box-shadow: 0 0 1px rgba(#000, .2);
background-size: cover;
}
.uuidValue {
composes: paramName;
composes: paramValue;

View File

@ -12,7 +12,7 @@ export type User = {|
isGuest: bool,
isActive: bool,
isOtpEnabled: bool,
passwordChangedAt: ?number,
passwordChangedAt: number,
hasMojangUsernameCollision: bool,
maskedEmail?: string,
shouldAcceptRules?: bool,
@ -32,7 +32,7 @@ const defaults: User = {
isActive: false,
isOtpEnabled: false,
shouldAcceptRules: false, // whether user need to review updated rules
passwordChangedAt: null,
passwordChangedAt: 0,
hasMojangUsernameCollision: false,
// frontend specific attributes

View File

@ -1,71 +1,83 @@
{
"be": {
"code": "be",
"name": "Беларуская",
"englishName": "Belarusian",
"progress": 100,
"isReleased": true
},
"en": {
"code": "en",
"name": "English, UK",
"englishName": "English, UK",
"progress": 100,
"isReleased": true
},
"fr": {
"code": "fr",
"name": "Français",
"englishName": "French",
"progress": 85.6,
"isReleased": true
},
"id": {
"code": "id",
"name": "Bahasa Indonesia",
"englishName": "Indonesian",
"progress": 96.2,
"isReleased": true
},
"lt": {
"code": "lt",
"name": "Lietuvių",
"englishName": "Lithuanian",
"progress": 92.8,
"isReleased": false
},
"pl": {
"code": "pl",
"name": "Polski",
"englishName": "Polish",
"progress": 81.8,
"isReleased": false
},
"pt": {
"code": "pt",
"name": "Português do Brasil",
"englishName": "Portuguese, Brazilian",
"progress": 96.2,
"isReleased": true
},
"ro": {
"code": "ro",
"name": "Română",
"englishName": "Romanian",
"progress": 81.8,
"isReleased": false
},
"ru": {
"code": "ru",
"name": "Русский",
"englishName": "Russian",
"progress": 100,
"isReleased": true
},
"sl": {
"code": "sl",
"name": "Slovenščina",
"englishName": "Slovenian",
"progress": 82.2,
"isReleased": false
},
"uk": {
"code": "uk",
"name": "Українська",
"englishName": "Ukrainian",
"progress": 85.6,
"isReleased": true
},
"vi": {
"code": "vi",
"name": "Tiếng Việt",
"englishName": "Vietnamese",
"progress": 96.2,

View File

@ -268,7 +268,7 @@ if (isProduction) {
];
webpackConfig.entry.vendor = Object.keys(packageJson.dependencies)
.filter((module) => ignoredPlugins.indexOf(module) === -1);
.filter((module) => !ignoredPlugins.includes(module));
} else {
webpackConfig.plugins.push(
new webpack.DllReferencePlugin({