Add localization (#602)
* #10 add localization * #10 revert locale from dev page * #10 add more examples * #10 localize auth panel * #10 localize some comment message * #10 localize some comment form message * #10 translate vote messages * #10 translate comment message * #10 use messages value for translate reference * #10 fix compile issue * #10 use messages value for translate reference * #10 use messages value for translate reference * #10 use messages value for translate reference * #10 use messages value for translate reference * #10 use messages value for translate reference * #10 translate comment message * #10 translate comment form * #10 translate comment form * #10 translate root component * #10 translate user info * #10 translate settings * #10 translate settings * #10 translate toolbar * #10 translate errors messages * #10 sort dict * #10 fix after rebase * #10 localize anonymousLoginForm * #10 localize emailLoginForm * #10 update size-limit * #10 localize subscribe by rss * #10 localize subscribe by email * #10 add de locale * #10 increase limit size * #10 auto generate loadLocale * #10 add some documentation * #10 fix error messages * fix typo * don't bundle en locale * fix message id * change size limits * Update extract message pattern Co-Authored-By: Pavel Mineev <pavel@mineev.me> Co-authored-by: Pavel Mineev <pavel@mineev.me>
This commit is contained in:
+1
-1
@@ -21,4 +21,4 @@ debug.test
|
||||
*.test
|
||||
remark42
|
||||
/backend/var/
|
||||
compose-private-backend.yml
|
||||
compose-private-backend.yml
|
||||
|
||||
@@ -434,7 +434,8 @@ Add this snippet to the bottom of web page:
|
||||
// in well defined order
|
||||
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
|
||||
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
|
||||
page_title: 'Moving to Remark42' // optional param; if it isn't defined `document.title` will be used
|
||||
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
|
||||
locale: 'en' // set up locale and language, if it isn't defined default value ('en') will be used
|
||||
};
|
||||
|
||||
(function(c) {
|
||||
@@ -469,6 +470,12 @@ Just call this function and pass a name of the theme that you want to turn on:
|
||||
window.REMARK42.changeTheme('light');
|
||||
```
|
||||
|
||||
##### Locales
|
||||
|
||||
Right now Remark has support three locales en, ru (partial translated), de(not translated).
|
||||
You can pick one using configuration object.
|
||||
Do you want support other locale? Please create [issue](https://github.com/umputun/remark42/issues).
|
||||
|
||||
#### Last comments
|
||||
|
||||
It's a widget which renders list of last comments from your site.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
.env
|
||||
extracted-messages
|
||||
|
||||
@@ -4,11 +4,11 @@ module.exports = [
|
||||
limit: '2.55 KB',
|
||||
},
|
||||
{
|
||||
limit: '68 KB',
|
||||
limit: '83 KB',
|
||||
path: 'public/remark.js',
|
||||
},
|
||||
{
|
||||
limit: '30 KB',
|
||||
limit: '45 KB',
|
||||
path: 'public/last-comments.js',
|
||||
},
|
||||
{
|
||||
|
||||
+24
-17
@@ -2,32 +2,39 @@
|
||||
|
||||
### Code Style
|
||||
|
||||
* project uses typescript to statically analyze code
|
||||
* project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
|
||||
* git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
|
||||
* if you want IDE integration, you need `eslint` plugin to be installed.
|
||||
- project uses typescript to statically analyze code
|
||||
- project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
|
||||
- git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
|
||||
- if you want IDE integration, you need `eslint` plugin to be installed.
|
||||
|
||||
### CSS Styles
|
||||
|
||||
* although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
|
||||
* component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
|
||||
* component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
|
||||
* component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
|
||||
* each style should be `require`d in `index.ts` of component's root directory
|
||||
- although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
|
||||
- component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
|
||||
- component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
|
||||
- component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
|
||||
- each style should be `require`d in `index.ts` of component's root directory
|
||||
|
||||
### Imports
|
||||
|
||||
* imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
|
||||
* if file resides in same directory or in subdirectory import should be relative: `./types/something`
|
||||
* otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest
|
||||
- imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
|
||||
- if file resides in same directory or in subdirectory import should be relative: `./types/something`
|
||||
- otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest
|
||||
|
||||
### Testing
|
||||
|
||||
* project uses `jest` as test harness.
|
||||
* jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
|
||||
* tests are running on push attempt
|
||||
* example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
|
||||
- project uses `jest` as test harness.
|
||||
- jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
|
||||
- tests are running on push attempt
|
||||
- example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
|
||||
|
||||
### how to add new locale.
|
||||
|
||||
- add new item to `./tasks/supportedLocales.json`
|
||||
- run `npm run generate-langs`
|
||||
- commit all changed files
|
||||
- translate all string in new generated dictionary `./app/locale/<new-locale>.json`
|
||||
|
||||
### Notes
|
||||
|
||||
* Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
|
||||
- Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
|
||||
|
||||
@@ -16,10 +16,12 @@ export interface CommentsConfig {
|
||||
theme?: Theme;
|
||||
page_title?: string;
|
||||
node?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface LastCommentsConfig {
|
||||
host: string;
|
||||
site_id: string;
|
||||
max_last_comments: number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Sorting, AuthProvider, BlockingDuration, Theme } from './types';
|
||||
import { Sorting, AuthProvider, Theme } from './types';
|
||||
import * as configConstant from './constants.config';
|
||||
|
||||
export const BASE_URL = configConstant.BASE_URL;
|
||||
@@ -33,25 +33,6 @@ export const LS_HIDDEN_USERS_KEY = '__remarkHiddenUsers';
|
||||
/** cookie key under which sort preference resides */
|
||||
export const COOKIE_SORT_KEY = 'remarkSort';
|
||||
|
||||
export const BLOCKING_DURATIONS: BlockingDuration[] = [
|
||||
{
|
||||
label: 'Permanently',
|
||||
value: 'permanently',
|
||||
},
|
||||
{
|
||||
label: 'For a month',
|
||||
value: '43200m',
|
||||
},
|
||||
{
|
||||
label: 'For a week',
|
||||
value: '10080m',
|
||||
},
|
||||
{
|
||||
label: 'For a day',
|
||||
value: '1440m',
|
||||
},
|
||||
];
|
||||
|
||||
export const THEMES: Theme[] = ['light', 'dark'];
|
||||
|
||||
export const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|Opera Mini|Windows Phone/i.test(navigator.userAgent);
|
||||
|
||||
@@ -56,9 +56,8 @@ describe('fetcher', () => {
|
||||
fail(data);
|
||||
})
|
||||
.catch(e => {
|
||||
expect(e.code).toBe(-1);
|
||||
expect(e.code).toBe(401);
|
||||
expect(e.error).toBe('Not authorized.');
|
||||
expect(e.details).toBe('Not authorized.');
|
||||
});
|
||||
});
|
||||
it('should throw "Something went wrong." object on unknown status', async () => {
|
||||
@@ -77,9 +76,8 @@ describe('fetcher', () => {
|
||||
fail(data);
|
||||
})
|
||||
.catch(e => {
|
||||
expect(e.code).toBe(-1);
|
||||
expect(e.code).toBe(0);
|
||||
expect(e.error).toBe('Something went wrong.');
|
||||
expect(e.details).toBe('you given me something wrong');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BASE_URL, API_BASE } from './constants';
|
||||
import { siteId } from './settings';
|
||||
import { StaticStore } from './static_store';
|
||||
import { getCookie } from './cookies';
|
||||
import { httpErrorMap } from '@app/utils/errorUtils';
|
||||
import { httpErrorMap, isFailedFetch, httpMessages } from '@app/utils/errorUtils';
|
||||
|
||||
export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
|
||||
const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head'];
|
||||
@@ -73,46 +73,54 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
|
||||
rurl += (rurl.includes('?') ? '&' : '?') + `site=${siteId}`;
|
||||
}
|
||||
|
||||
return fetch(rurl, parameters).then(res => {
|
||||
const date = (res.headers.has('date') && res.headers.get('date')) || '';
|
||||
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
|
||||
const timeDiff = (new Date().getTime() - timestamp) / 1000;
|
||||
StaticStore.serverClientTimeDiff = timeDiff;
|
||||
return fetch(rurl, parameters)
|
||||
.then(res => {
|
||||
const date = (res.headers.has('date') && res.headers.get('date')) || '';
|
||||
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
|
||||
const timeDiff = (new Date().getTime() - timestamp) / 1000;
|
||||
StaticStore.serverClientTimeDiff = timeDiff;
|
||||
|
||||
if (res.status >= 400) {
|
||||
if (httpErrorMap.has(res.status)) {
|
||||
const errString = httpErrorMap.get(res.status)!;
|
||||
throw {
|
||||
code: -1,
|
||||
error: errString,
|
||||
details: errString,
|
||||
};
|
||||
}
|
||||
return res.text().then(text => {
|
||||
let err;
|
||||
try {
|
||||
err = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (logError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
if (httpErrorMap.has(res.status)) {
|
||||
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
|
||||
throw {
|
||||
code: -1,
|
||||
error: 'Something went wrong.',
|
||||
details: text,
|
||||
code: res.status,
|
||||
error: descriptor.defaultMessage,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return res.text().then(text => {
|
||||
let err;
|
||||
try {
|
||||
err = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (logError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
throw {
|
||||
code: 0,
|
||||
error: httpMessages.unexpectedError.defaultMessage,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
|
||||
return res.json();
|
||||
}
|
||||
if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return res.text();
|
||||
});
|
||||
return res.text();
|
||||
})
|
||||
.catch(e => {
|
||||
if (isFailedFetch(e)) {
|
||||
throw {
|
||||
code: -2,
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
return acc;
|
||||
}, {}) as FetcherObject;
|
||||
|
||||
+24
-5
@@ -1,6 +1,7 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
import { IntlShape, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
import { Input } from '@app/components/input';
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
onSubmit(username: string): Promise<void>;
|
||||
theme: Theme;
|
||||
className?: string;
|
||||
intl: IntlShape;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -17,6 +19,22 @@ interface State {
|
||||
honeyPotValue: boolean;
|
||||
}
|
||||
|
||||
export const messages = defineMessages({
|
||||
lengthLimit: {
|
||||
id: 'anonymousLoginForm.length-limit',
|
||||
defaultMessage: 'Username must be at least 3 characters long',
|
||||
},
|
||||
symbolLimit: {
|
||||
id: 'anonymousLoginForm.symbol-limit',
|
||||
defaultMessage:
|
||||
'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces',
|
||||
},
|
||||
userName: {
|
||||
id: 'anonymousLoginForm.user-name',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
});
|
||||
|
||||
export class AnonymousLoginForm extends Component<Props, State> {
|
||||
static usernameRegex = /^[a-zA-Z][\w ]+$/;
|
||||
|
||||
@@ -51,9 +69,9 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
|
||||
getUsernameInvalidReason(): string | null {
|
||||
const value = this.state.inputValue;
|
||||
if (value.length < 3) return 'Username must be at least 3 characters long';
|
||||
if (!AnonymousLoginForm.usernameRegex.test(value))
|
||||
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
|
||||
const intl = this.props.intl;
|
||||
if (value.length < 3) return intl.formatMessage(messages.lengthLimit);
|
||||
if (!AnonymousLoginForm.usernameRegex.test(value)) return intl.formatMessage(messages.symbolLimit);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,6 +87,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
const intl = props.intl;
|
||||
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
|
||||
let className = b('auth-panel-anonymous-login-form', {}, { theme: props.theme });
|
||||
if (props.className) {
|
||||
@@ -82,7 +101,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
mix="auth-panel-anonymous-login-form__input"
|
||||
placeholder="Username"
|
||||
placeholder={intl.formatMessage(messages.userName)}
|
||||
value={this.state.inputValue}
|
||||
onInput={this.onChange}
|
||||
/>
|
||||
@@ -103,7 +122,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
title={usernameInvalidReason || ''}
|
||||
disabled={usernameInvalidReason !== null}
|
||||
>
|
||||
Log in
|
||||
<FormattedMessage id="anonymousLoginForm.log-in" defaultMessage="Log in" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
+41
-43
@@ -1,45 +1,52 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { mount } from 'enzyme';
|
||||
import { EmailLoginForm, Props, State } from './auth-panel__email-login-form';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth-panel__email-login-form';
|
||||
import { User } from '@app/common/types';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { validToken } from '@app/testUtils/mocks/jwt';
|
||||
import { sendEmailVerificationRequest } from '@app/common/api';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import enMessages from '../../../locales/en.json';
|
||||
|
||||
jest.mock('@app/utils/jwt', () => ({
|
||||
isJwtExpired: jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => true)
|
||||
.mockImplementationOnce(() => false)
|
||||
.mockImplementationOnce(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('@app/common/api');
|
||||
|
||||
function simulateInput(input: ReactWrapper, value: string) {
|
||||
input.getDOMNode<HTMLTextAreaElement>().value = value;
|
||||
input.simulate('input');
|
||||
}
|
||||
|
||||
describe('EmailLoginForm', () => {
|
||||
const testUser = ({} as any) as User;
|
||||
const onSuccess = jest.fn(async () => {});
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
beforeEach(() => {
|
||||
(sendEmailVerificationRequest as any).mockReset();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
const sendEmailVerification = jest.fn(async () => {});
|
||||
|
||||
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
|
||||
const el = mount<Props, State>(
|
||||
<EmailLoginForm
|
||||
sendEmailVerification={sendEmailVerification}
|
||||
onSignIn={onSignIn}
|
||||
onSuccess={onSuccess}
|
||||
theme="light"
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
await new Promise(resolve =>
|
||||
el.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
|
||||
);
|
||||
|
||||
simulateInput(el.find(`input[name="email"]`), 'someone@example.com');
|
||||
simulateInput(el.find(`input[name="username"]`), 'someone');
|
||||
el.find('form').simulate('submit');
|
||||
await sleep(100);
|
||||
expect(sendEmailVerification).toBeCalledWith('someone', 'someone@example.com');
|
||||
expect(el.state().verificationSent).toBe(true);
|
||||
|
||||
await new Promise(resolve => el.setState({ tokenValue: 'abcd' } as State, resolve));
|
||||
expect(sendEmailVerificationRequest).toBeCalledWith('someone', 'someone@example.com');
|
||||
el.update();
|
||||
simulateInput(el.find(`textarea[name="token"]`), 'abcd');
|
||||
|
||||
el.find('form').simulate('submit');
|
||||
await sleep(100);
|
||||
@@ -48,45 +55,36 @@ describe('EmailLoginForm', () => {
|
||||
});
|
||||
|
||||
it('should send form by pasting token', async () => {
|
||||
const sendEmailVerification = jest.fn(async () => {});
|
||||
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
const wrapper = mount<Props, State>(
|
||||
<EmailLoginForm
|
||||
sendEmailVerification={sendEmailVerification}
|
||||
onSignIn={onSignIn}
|
||||
onSuccess={onSuccess}
|
||||
theme="light"
|
||||
/>
|
||||
);
|
||||
await new Promise(resolve =>
|
||||
wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
|
||||
</IntlProvider>
|
||||
);
|
||||
simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com');
|
||||
simulateInput(wrapper.find(`input[name="username"]`), 'someone');
|
||||
wrapper.find('form').simulate('submit');
|
||||
await sleep(100);
|
||||
wrapper.update();
|
||||
|
||||
wrapper.find('textarea').getDOMNode<HTMLTextAreaElement>().value = validToken;
|
||||
wrapper.find('textarea').simulate('input');
|
||||
|
||||
simulateInput(wrapper.find(`textarea[name="token"]`), validToken);
|
||||
await sleep(100);
|
||||
wrapper.update();
|
||||
expect(onSignIn).toBeCalledWith(validToken);
|
||||
});
|
||||
|
||||
it('should show error "Token is expired" on paste', async () => {
|
||||
const sendEmailVerification = jest.fn(async () => {});
|
||||
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
const wrapper = mount<Props, State>(
|
||||
<EmailLoginForm
|
||||
sendEmailVerification={sendEmailVerification}
|
||||
onSignIn={onSignIn}
|
||||
onSuccess={onSuccess}
|
||||
theme="light"
|
||||
/>
|
||||
);
|
||||
await new Promise(resolve =>
|
||||
wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
|
||||
</IntlProvider>
|
||||
);
|
||||
simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com');
|
||||
simulateInput(wrapper.find(`input[name="username"]`), 'someone');
|
||||
wrapper.find('form').simulate('submit');
|
||||
await sleep(100);
|
||||
wrapper.update();
|
||||
|
||||
+63
-25
@@ -2,7 +2,6 @@
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import b from 'bem-react-helper';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Theme, User } from '@app/common/types';
|
||||
import { sendEmailVerificationRequest } from '@app/common/api';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
@@ -12,10 +11,9 @@ import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
import { isJwtExpired } from '@app/utils/jwt';
|
||||
import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
const mapStateToProps = () => ({
|
||||
sendEmailVerification: sendEmailVerificationRequest,
|
||||
});
|
||||
import { messages as loginForm } from '../__anonymous-login-form/auth-panel__anonymous-login-form';
|
||||
|
||||
interface OwnProps {
|
||||
onSignIn(token: string): Promise<User | null>;
|
||||
@@ -24,7 +22,7 @@ interface OwnProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & ReturnType<typeof mapStateToProps>;
|
||||
export type Props = OwnProps & { intl: IntlShape; sendEmailVerification: typeof sendEmailVerificationRequest };
|
||||
|
||||
export interface State {
|
||||
usernameValue: string;
|
||||
@@ -35,6 +33,37 @@ export interface State {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
expiredToken: {
|
||||
id: 'emailLoginForm.expired-token',
|
||||
defaultMessage: 'Token is expired',
|
||||
},
|
||||
userNotFound: {
|
||||
id: 'emailLoginForm.user-not-found',
|
||||
defaultMessage: 'No user was found',
|
||||
},
|
||||
loading: {
|
||||
id: 'emailLoginForm.loading',
|
||||
defaultMessage: 'Loading...',
|
||||
},
|
||||
invalidEmail: {
|
||||
id: 'emailLoginForm.invalid-email',
|
||||
defaultMessage: 'Address should be valid email address',
|
||||
},
|
||||
emptyToken: {
|
||||
id: 'emailLoginForm.empty-token',
|
||||
defaultMessage: 'Token field must not be empty',
|
||||
},
|
||||
emailAddress: {
|
||||
id: 'emailLoginForm.email-address',
|
||||
defaultMessage: 'Email Address',
|
||||
},
|
||||
token: {
|
||||
id: 'emailLoginForm.token',
|
||||
defaultMessage: 'Token',
|
||||
},
|
||||
});
|
||||
|
||||
export class EmailLoginForm extends Component<Props, State> {
|
||||
static usernameRegex = /^[a-zA-Z][\w ]+$/;
|
||||
static emailRegex = /[^@]+@[^.]+\..+/;
|
||||
@@ -70,24 +99,27 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
this.tokenRef.current && this.tokenRef.current.focus();
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
this.setState({ error: extractErrorMessageFromResponse(e) });
|
||||
this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) });
|
||||
} finally {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
async sendForm(token: string = this.state.tokenValue) {
|
||||
const intl = this.props.intl;
|
||||
try {
|
||||
this.setState({ loading: true });
|
||||
const user = await this.props.onSignIn(token);
|
||||
if (!user) {
|
||||
this.setState({ error: 'No user was found' });
|
||||
this.setState({ error: intl.formatMessage(messages.userNotFound) });
|
||||
return;
|
||||
}
|
||||
this.setState({ verificationSent: false, tokenValue: '' });
|
||||
this.props.onSuccess && this.props.onSuccess(user);
|
||||
if (this.props.onSuccess) {
|
||||
await this.props.onSuccess(user);
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({ error: extractErrorMessageFromResponse(e) });
|
||||
this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) });
|
||||
} finally {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
@@ -107,13 +139,14 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
};
|
||||
|
||||
onTokenChange = (e: Event) => {
|
||||
const intl = this.props.intl;
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
|
||||
this.setState({ error: null, tokenValue: value });
|
||||
|
||||
try {
|
||||
if (value.length > 0 && isJwtExpired(value)) {
|
||||
this.setState({ error: 'Token is expired' });
|
||||
this.setState({ error: intl.formatMessage(messages.expiredToken) });
|
||||
return;
|
||||
}
|
||||
this.sendForm(value);
|
||||
@@ -140,22 +173,24 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
};
|
||||
|
||||
getForm1InvalidReason(): string | null {
|
||||
if (this.state.loading) return 'Loading...';
|
||||
const intl = this.props.intl;
|
||||
if (this.state.loading) return intl.formatMessage(messages.loading);
|
||||
const username = this.state.usernameValue;
|
||||
if (username.length < 3) return 'Username must be at least 3 characters long';
|
||||
if (!EmailLoginForm.usernameRegex.test(username))
|
||||
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
|
||||
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return 'Address should be valid email address';
|
||||
if (username.length < 3) return intl.formatMessage(loginForm.lengthLimit);
|
||||
if (!EmailLoginForm.usernameRegex.test(username)) return intl.formatMessage(loginForm.symbolLimit);
|
||||
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return intl.formatMessage(messages.invalidEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
getForm2InvalidReason(): string | null {
|
||||
if (this.state.loading) return 'Loading...';
|
||||
if (this.state.tokenValue.length === 0) return 'Token field must not be empty';
|
||||
const intl = this.props.intl;
|
||||
if (this.state.loading) return intl.formatMessage(messages.loading);
|
||||
if (this.state.tokenValue.length === 0) return intl.formatMessage(messages.emptyToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(props: Props) {
|
||||
const intl = props.intl;
|
||||
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
|
||||
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
|
||||
if (props.className) {
|
||||
@@ -169,16 +204,18 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
<form className={className} onSubmit={this.onVerificationSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
name="username"
|
||||
mix="auth-panel-email-login-form__input"
|
||||
ref={this.usernameInputRef}
|
||||
placeholder="Username"
|
||||
placeholder={intl.formatMessage(loginForm.userName)}
|
||||
value={this.state.usernameValue}
|
||||
onInput={this.onUsernameChange}
|
||||
/>
|
||||
<Input
|
||||
mix="auth-panel-email-login-form__input"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
name="email"
|
||||
placeholder={intl.formatMessage(messages.emailAddress)}
|
||||
value={this.state.addressValue}
|
||||
onInput={this.onAddressChange}
|
||||
/>
|
||||
@@ -191,7 +228,7 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
title={form1InvalidReason || ''}
|
||||
disabled={form1InvalidReason !== null}
|
||||
>
|
||||
Send Verification
|
||||
<FormattedMessage id="emailLoginForm.send-verification" defaultMessage="Send Verification" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -201,13 +238,14 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
return (
|
||||
<form className={className} onSubmit={this.onSubmit}>
|
||||
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
|
||||
Back
|
||||
<FormattedMessage id="emailLoginForm.back" defaultMessage="Back" />
|
||||
</Button>
|
||||
<TextareaAutosize
|
||||
autofocus={true}
|
||||
name="token"
|
||||
className="auth-panel-email-login-form__token-input"
|
||||
ref={this.tokenRef}
|
||||
placeholder="Token"
|
||||
placeholder={intl.formatMessage(messages.token)}
|
||||
value={this.state.tokenValue}
|
||||
onInput={this.onTokenChange}
|
||||
spellcheck={false}
|
||||
@@ -222,7 +260,7 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
title={form2InvalidReason || ''}
|
||||
disabled={form2InvalidReason !== null}
|
||||
>
|
||||
Confirm
|
||||
<FormattedMessage id="emailLoginForm.confirm" defaultMessage="Confirm" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -232,6 +270,6 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
export type EmailLoginFormRef = EmailLoginForm;
|
||||
|
||||
export const EmailLoginFormConnected = forwardRef<EmailLoginForm, OwnProps>((props, ref) => {
|
||||
const connectedProps = useSelector(mapStateToProps);
|
||||
return <EmailLoginForm {...props} {...connectedProps} ref={ref} />;
|
||||
const intl = useIntl();
|
||||
return <EmailLoginForm {...props} sendEmailVerification={sendEmailVerificationRequest} intl={intl} ref={ref} />;
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@ import { mount } from 'enzyme';
|
||||
import { Button } from '@app/components/button';
|
||||
import { User, PostInfo } from '@app/common/types';
|
||||
|
||||
import { Props, AuthPanel } from './auth-panel';
|
||||
import { Props, AuthPanelWithIntl as AuthPanel } from './auth-panel';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import enMessages from '../../locales/en.json';
|
||||
|
||||
const DefaultProps: Partial<Props> = {
|
||||
sort: '-score',
|
||||
@@ -22,7 +24,11 @@ const DefaultProps: Partial<Props> = {
|
||||
describe('<AuthPanel />', () => {
|
||||
describe('For not authorized user', () => {
|
||||
it('should render login form with google and github provider', () => {
|
||||
const element = mount(<AuthPanel {...(DefaultProps as Props)} user={null} />);
|
||||
const element = mount(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel {...(DefaultProps as Props)} user={null} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const authPanelColumn = element.find('.auth-panel__column');
|
||||
|
||||
@@ -41,12 +47,14 @@ describe('<AuthPanel />', () => {
|
||||
describe('sorting', () => {
|
||||
it('should place selected provider first', () => {
|
||||
const element = mount(
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
providers={['google', 'github', 'yandex']}
|
||||
provider={{ name: 'github' }}
|
||||
user={null}
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
providers={['google', 'github', 'yandex']}
|
||||
provider={{ name: 'github' }}
|
||||
user={null}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const providerLinks = element
|
||||
@@ -61,12 +69,14 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
it('should do nothing if provider not found', () => {
|
||||
const element = mount(
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
providers={['google', 'github', 'yandex']}
|
||||
provider={{ name: 'baidu' }}
|
||||
user={null}
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
providers={['google', 'github', 'yandex']}
|
||||
provider={{ name: 'baidu' }}
|
||||
user={null}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const providerLinks = element
|
||||
@@ -82,11 +92,13 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
it('should render login form with google and github provider for read-only post', () => {
|
||||
const element = mount(
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const authPanelColumn = element.find('.auth-panel__column');
|
||||
@@ -105,11 +117,13 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
it('should not render settings if there is no hidden users', () => {
|
||||
const element = mount(
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const adminAction = element.find('.auth-panel__admin-action');
|
||||
@@ -119,12 +133,14 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
it('should render settings if there is some hidden users', () => {
|
||||
const element = mount(
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
hiddenUsers={{ hidden_joe: {} as any }}
|
||||
/>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel
|
||||
{...(DefaultProps as Props)}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
hiddenUsers={{ hidden_joe: {} as any }}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const adminAction = element.find('.auth-panel__admin-action');
|
||||
@@ -134,7 +150,11 @@ describe('<AuthPanel />', () => {
|
||||
});
|
||||
describe('For authorized user', () => {
|
||||
it('should render info about current user', () => {
|
||||
const element = mount(<AuthPanel {...(DefaultProps as Props)} user={{ id: `john`, name: 'John' } as User} />);
|
||||
const element = mount(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel {...(DefaultProps as Props)} user={{ id: `john`, name: 'John' } as User} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const authPanelColumn = element.find('.auth-panel__column');
|
||||
|
||||
@@ -148,7 +168,9 @@ describe('<AuthPanel />', () => {
|
||||
describe('For admin user', () => {
|
||||
it('should render admin action', () => {
|
||||
const element = mount(
|
||||
<AuthPanel {...(DefaultProps as Props)} user={{ id: `test`, admin: true, name: 'John' } as User} />
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<AuthPanel {...(DefaultProps as Props)} user={{ id: `test`, admin: true, name: 'John' } as User} />{' '}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const adminAction = element.find('.auth-panel__admin-action').first();
|
||||
|
||||
@@ -13,12 +13,13 @@ import { StoreState } from '@app/store';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
import { Dropdown, DropdownItem } from '@app/components/dropdown';
|
||||
import { Button } from '@app/components/button';
|
||||
import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl';
|
||||
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
import { EmailLoginFormConnected } from './__email-login-form';
|
||||
import { EmailLoginFormRef } from './__email-login-form/auth-panel__email-login-form';
|
||||
|
||||
export interface Props {
|
||||
interface PropsWithoutIntl {
|
||||
user: User | null;
|
||||
hiddenUsers: StoreState['hiddenUsers'];
|
||||
sort: Sorting;
|
||||
@@ -37,6 +38,8 @@ export interface Props {
|
||||
onBlockedUsersHide(): void;
|
||||
}
|
||||
|
||||
export type Props = PropsWithoutIntl & { intl: IntlShape };
|
||||
|
||||
interface State {
|
||||
isBlockedVisible: boolean;
|
||||
anonymousUsernameInputValue: string;
|
||||
@@ -44,6 +47,21 @@ interface State {
|
||||
sortSelectFocused: boolean;
|
||||
}
|
||||
|
||||
const authPanelMessages = defineMessages({
|
||||
otherProvider: {
|
||||
id: 'authPanel.other-provider',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
anonymousProvider: {
|
||||
id: 'authPanel.anonymous-provider',
|
||||
defaultMessage: 'Anonymous',
|
||||
},
|
||||
orProvider: {
|
||||
id: 'authPanel.or-provider',
|
||||
defaultMessage: 'or',
|
||||
},
|
||||
});
|
||||
|
||||
export class AuthPanel extends Component<Props, State> {
|
||||
emailLoginRef = createRef<EmailLoginFormRef>();
|
||||
|
||||
@@ -155,7 +173,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
You logged in as{' '}
|
||||
<FormattedMessage id="authPanel.logged-as" defaultMessage="You logged in as" />{' '}
|
||||
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
|
||||
<DropdownItem separator={!isUserAnonymous}>
|
||||
<div
|
||||
@@ -170,13 +188,13 @@ export class AuthPanel extends Component<Props, State> {
|
||||
{!isUserAnonymous && (
|
||||
<DropdownItem>
|
||||
<Button theme={theme} onClick={() => requestDeletion().then(onSignOut)}>
|
||||
Request my data removal
|
||||
<FormattedMessage id="authPanel.request-to-delete-data" defaultMessage="Request my data removal" />
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</Dropdown>{' '}
|
||||
<Button kind="link" theme={theme} onClick={onSignOut}>
|
||||
Logout?
|
||||
<FormattedMessage id="authPanel.logout" defaultMessage="Logout?" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -184,9 +202,10 @@ export class AuthPanel extends Component<Props, State> {
|
||||
|
||||
renderProvider = (provider: AuthProvider['name'], dropdown = false) => {
|
||||
if (provider === 'anonymous') {
|
||||
const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider);
|
||||
return (
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES['anonymous']}
|
||||
title={anonymous}
|
||||
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
|
||||
theme={this.props.theme}
|
||||
>
|
||||
@@ -195,6 +214,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
onSubmit={this.handleAnonymousLoginFormSubmut}
|
||||
theme={this.props.theme}
|
||||
className="auth-panel__anonymous-login-form"
|
||||
intl={this.props.intl}
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
@@ -234,8 +254,9 @@ export class AuthPanel extends Component<Props, State> {
|
||||
};
|
||||
|
||||
renderOther = (providers: AuthProvider['name'][]) => {
|
||||
const other = this.props.intl.formatMessage(authPanelMessages.otherProvider);
|
||||
return (
|
||||
<Dropdown title="Other" theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
|
||||
<Dropdown title={other} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
|
||||
{providers.map(provider => (
|
||||
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
|
||||
))}
|
||||
@@ -260,13 +281,13 @@ export class AuthPanel extends Component<Props, State> {
|
||||
})();
|
||||
|
||||
const isAboveThreshold = sortedProviders.length > threshold;
|
||||
|
||||
const or = this.props.intl.formatMessage(authPanelMessages.orProvider);
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
{'Login: '}
|
||||
<FormattedMessage id="authPanel.login" defaultMessage="Login:" />{' '}
|
||||
{!isAboveThreshold &&
|
||||
sortedProviders.map((provider, i) => {
|
||||
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ' or ' : ', ';
|
||||
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', ';
|
||||
|
||||
return (
|
||||
<span>
|
||||
@@ -288,7 +309,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
})}
|
||||
{isAboveThreshold && (
|
||||
<span>
|
||||
{' or '}
|
||||
{` ${or} `}
|
||||
{this.renderOther(sortedProviders.slice(threshold - 1))}
|
||||
</span>
|
||||
)}
|
||||
@@ -300,7 +321,10 @@ export class AuthPanel extends Component<Props, State> {
|
||||
if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null;
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
Disable third-party cookies blocking to login or open comments in{' '}
|
||||
<FormattedMessage
|
||||
id="authPanel.disabled-cookies"
|
||||
defaultMessage="Disable third-party cookies blocking to login or open comments in"
|
||||
/>{' '}
|
||||
<a
|
||||
className="auth-panel__pseudo-link"
|
||||
href={`${window.location.origin}/web/comments.html${window.location.search}`}
|
||||
@@ -314,7 +338,11 @@ export class AuthPanel extends Component<Props, State> {
|
||||
|
||||
renderCookiesWarning = () => {
|
||||
if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null;
|
||||
return <div className="auth-panel__column">Allow cookies to login and comment</div>;
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
<FormattedMessage id="authPanel.enable-cookies" defaultMessage="Allow cookies to login and comment" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSettingsLabel = () => {
|
||||
@@ -325,7 +353,11 @@ export class AuthPanel extends Component<Props, State> {
|
||||
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
|
||||
role="link"
|
||||
>
|
||||
{this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
|
||||
{this.state.isBlockedVisible ? (
|
||||
<FormattedMessage id="authPanel.hide-settings" defaultMessage="Hide settings" />
|
||||
) : (
|
||||
<FormattedMessage id="authPanel.show-settings" defaultMessage="Show settings" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -339,7 +371,11 @@ export class AuthPanel extends Component<Props, State> {
|
||||
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
|
||||
role="link"
|
||||
>
|
||||
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
|
||||
{isCommentsDisabled ? (
|
||||
<FormattedMessage id="authPanel.enable-comments" defaultMessage="Enable comments" />
|
||||
) : (
|
||||
<FormattedMessage id="authPanel.disable-comments" defaultMessage="Disable comments" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -347,10 +383,10 @@ export class AuthPanel extends Component<Props, State> {
|
||||
renderSort = () => {
|
||||
const { sort } = this.props;
|
||||
const { sortSelectFocused } = this.state;
|
||||
const sortArray = getSortArray(sort);
|
||||
const sortArray = getSortArray(sort, this.props.intl);
|
||||
return (
|
||||
<span className="auth-panel__sort">
|
||||
Sort by{' '}
|
||||
<FormattedMessage id="commentSort.sort-by" defaultMessage="Sort by" />{' '}
|
||||
<span className="auth-panel__select-label">
|
||||
<span className={b('auth-panel__select-label-value', {}, { focused: sortSelectFocused })}>
|
||||
{sortArray.find(x => 'selected' in x && x.selected!)!.label}
|
||||
@@ -396,7 +432,11 @@ export class AuthPanel extends Component<Props, State> {
|
||||
|
||||
{isAdmin && ' • '}
|
||||
|
||||
{!isAdmin && read_only && <span className="auth-panel__readonly-label">Read-only</span>}
|
||||
{!isAdmin && read_only && (
|
||||
<span className="auth-panel__readonly-label">
|
||||
<FormattedMessage id="authPanel.read-only" defaultMessage="Read-only" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{this.renderSort()}
|
||||
</div>
|
||||
@@ -405,7 +445,42 @@ export class AuthPanel extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
function getSortArray(currentSort: Sorting) {
|
||||
const sortMessages = defineMessages({
|
||||
best: {
|
||||
id: 'commentsSort.best',
|
||||
defaultMessage: 'Best',
|
||||
},
|
||||
worst: {
|
||||
id: 'commentsSort.worst',
|
||||
defaultMessage: 'Worst',
|
||||
},
|
||||
newest: {
|
||||
id: 'commentsSort.newest',
|
||||
defaultMessage: 'Newest',
|
||||
},
|
||||
oldest: {
|
||||
id: 'commentsSort.oldest',
|
||||
defaultMessage: 'Oldest',
|
||||
},
|
||||
recentlyUpdated: {
|
||||
id: 'commentsSort.recently-updated',
|
||||
defaultMessage: 'Recently updated',
|
||||
},
|
||||
leastRecentlyUpdated: {
|
||||
id: 'commentsSort.least-recently-updated',
|
||||
defaultMessage: 'Least recently updated',
|
||||
},
|
||||
mostControversial: {
|
||||
id: 'commentsSort.most-controversial',
|
||||
defaultMessage: 'Most controversial',
|
||||
},
|
||||
leastControversial: {
|
||||
id: 'commentsSort.least-controversial',
|
||||
defaultMessage: 'Least controversial',
|
||||
},
|
||||
});
|
||||
|
||||
function getSortArray(currentSort: Sorting, intl: IntlShape) {
|
||||
const sortArray: {
|
||||
value: Sorting;
|
||||
label: string;
|
||||
@@ -413,35 +488,35 @@ function getSortArray(currentSort: Sorting) {
|
||||
}[] = [
|
||||
{
|
||||
value: '-score',
|
||||
label: 'Best',
|
||||
label: intl.formatMessage(sortMessages.best),
|
||||
},
|
||||
{
|
||||
value: '+score',
|
||||
label: 'Worst',
|
||||
label: intl.formatMessage(sortMessages.worst),
|
||||
},
|
||||
{
|
||||
value: '-time',
|
||||
label: 'Newest',
|
||||
label: intl.formatMessage(sortMessages.newest),
|
||||
},
|
||||
{
|
||||
value: '+time',
|
||||
label: 'Oldest',
|
||||
label: intl.formatMessage(sortMessages.oldest),
|
||||
},
|
||||
{
|
||||
value: '-active',
|
||||
label: 'Recently updated',
|
||||
label: intl.formatMessage(sortMessages.recentlyUpdated),
|
||||
},
|
||||
{
|
||||
value: '+active',
|
||||
label: 'Least recently updated',
|
||||
label: intl.formatMessage(sortMessages.leastRecentlyUpdated),
|
||||
},
|
||||
{
|
||||
value: '-controversy',
|
||||
label: 'Most controversial',
|
||||
label: intl.formatMessage(sortMessages.mostControversial),
|
||||
},
|
||||
{
|
||||
value: '+controversy',
|
||||
label: 'Least controversial',
|
||||
label: intl.formatMessage(sortMessages.leastControversial),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -453,3 +528,8 @@ function getSortArray(currentSort: Sorting) {
|
||||
return sort;
|
||||
});
|
||||
}
|
||||
|
||||
export const AuthPanelWithIntl = (props: PropsWithoutIntl) => {
|
||||
const intl = useIntl();
|
||||
return <AuthPanel intl={intl} {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { AuthPanel } from './auth-panel';
|
||||
export { AuthPanelWithIntl as AuthPanel } from './auth-panel';
|
||||
|
||||
import './auth-panel.scss';
|
||||
|
||||
|
||||
+15
-9
@@ -16,6 +16,8 @@ import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
import { Dropdown } from '@app/components/dropdown';
|
||||
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import enMessages from '../../../locales/en.json';
|
||||
|
||||
import { SubscribeByEmail, SubscribeByEmailForm } from './';
|
||||
|
||||
@@ -40,9 +42,11 @@ jest.mock('@app/utils/jwt', () => ({
|
||||
describe('<SubscribeByEmail/>', () => {
|
||||
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmail />
|
||||
</Provider>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmail />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
it('should be rendered with disabled email button when user is anonymous', () => {
|
||||
@@ -67,9 +71,11 @@ describe('<SubscribeByEmail/>', () => {
|
||||
describe('<SubscribeByEmailForm/>', () => {
|
||||
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmailForm />
|
||||
</Provider>
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmailForm />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
it('should render email form by default', () => {
|
||||
const store = mockStore(initialStore);
|
||||
@@ -130,7 +136,7 @@ describe('<SubscribeByEmailForm/>', () => {
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
|
||||
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
|
||||
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
|
||||
});
|
||||
|
||||
it('should send form by paste valid token', async () => {
|
||||
@@ -156,7 +162,7 @@ describe('<SubscribeByEmailForm/>', () => {
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
|
||||
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
|
||||
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
|
||||
});
|
||||
|
||||
it('should pass throw unsubscribe process', async () => {
|
||||
@@ -175,6 +181,6 @@ describe('<SubscribeByEmailForm/>', () => {
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.text()).toStartWith('You have been unsubscribed by email to updates');
|
||||
expect(wrapper.find(Button).prop('children')).toEqual('Close');
|
||||
expect(wrapper.find(Button).text()).toEqual('Close');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-17
@@ -23,6 +23,7 @@ import { Preloader } from '@app/components/preloader';
|
||||
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
|
||||
import { isJwtExpired } from '@app/utils/jwt';
|
||||
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
|
||||
|
||||
const emailRegex = /[^@]+@[^.]+\..+/;
|
||||
|
||||
@@ -35,18 +36,60 @@ enum Step {
|
||||
Unsubscribed,
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
haveSubscribed: {
|
||||
id: 'subscribeByEmail.have-been-subscribed',
|
||||
defaultMessage: 'You have been subscribed on updates by email',
|
||||
},
|
||||
subscribed: {
|
||||
id: 'subscribeByEmail.subscribed',
|
||||
defaultMessage: 'You are subscribed on updates by email',
|
||||
},
|
||||
submit: {
|
||||
id: 'subscribeByEmail.submit',
|
||||
defaultMessage: 'Submit',
|
||||
},
|
||||
subscribe: {
|
||||
id: 'subscribeByEmail.subscribe',
|
||||
defaultMessage: 'Subscribe',
|
||||
},
|
||||
subscribeByEmail: {
|
||||
id: 'subscribeByEmail.subscribe-by-email',
|
||||
defaultMessage: 'Subscribe by Email',
|
||||
},
|
||||
onlyRegisteredUsers: {
|
||||
id: 'subscribeByEmail.only-registered-users',
|
||||
defaultMessage: 'Available only for registered users',
|
||||
},
|
||||
expiredToken: {
|
||||
id: 'subscribeByEmail.expired-token',
|
||||
defaultMessage: 'Expired token',
|
||||
},
|
||||
token: {
|
||||
id: 'subscribeByEmail.token',
|
||||
defaultMessage: 'Token',
|
||||
},
|
||||
email: {
|
||||
id: 'subscribeByEmail.email',
|
||||
defaultMessage: 'Email',
|
||||
},
|
||||
});
|
||||
|
||||
const renderEmailPart = (
|
||||
loading: boolean,
|
||||
intl: IntlShape,
|
||||
emailAddress: string,
|
||||
handleChangeEmail: (e: Event) => void,
|
||||
emailAddressRef: ReturnType<typeof useRef>
|
||||
) => (
|
||||
<Fragment>
|
||||
<div className="comment-form__subscribe-by-email__title">Subscribe to replies</div>
|
||||
<div className="comment-form__subscribe-by-email__title">
|
||||
<FormattedMessage id="subscribeByEmail.subscribe-to-replies" defaultMessage="Subscribe to replies" />
|
||||
</div>
|
||||
<Input
|
||||
ref={emailAddressRef}
|
||||
mix="comment-form__subscribe-by-email__input"
|
||||
placeholder="Email"
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
value={emailAddress}
|
||||
onInput={handleChangeEmail}
|
||||
disabled={loading}
|
||||
@@ -56,17 +99,18 @@ const renderEmailPart = (
|
||||
|
||||
const renderTokenPart = (
|
||||
loading: boolean,
|
||||
intl: IntlShape,
|
||||
token: string,
|
||||
handleChangeToken: (e: Event) => void,
|
||||
setEmailStep: () => void
|
||||
) => (
|
||||
<Fragment>
|
||||
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
|
||||
Back
|
||||
<FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" />
|
||||
</Button>
|
||||
<TextareaAutosize
|
||||
className="comment-form__subscribe-by-email__token-input"
|
||||
placeholder="Token"
|
||||
placeholder={intl.formatMessage(messages.token)}
|
||||
autofocus
|
||||
onInput={handleChangeToken}
|
||||
disabled={loading}
|
||||
@@ -78,6 +122,7 @@ const renderTokenPart = (
|
||||
export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const subscribed = useSelector<StoreState, boolean>(({ user }) =>
|
||||
user === null ? false : Boolean(user.email_subscription)
|
||||
);
|
||||
@@ -114,7 +159,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(extractErrorMessageFromResponse(e));
|
||||
setError(extractErrorMessageFromResponse(e, intl));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -139,7 +184,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
|
||||
try {
|
||||
if (value.length > 0 && isJwtExpired(value)) {
|
||||
setError('Token is expired');
|
||||
setError(intl.formatMessage(messages.expiredToken));
|
||||
} else {
|
||||
sendForm(value);
|
||||
}
|
||||
@@ -189,7 +234,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
previousStep.current = Step.Subscribed;
|
||||
setStep(Step.Unsubscribed);
|
||||
} catch (e) {
|
||||
setError(extractErrorMessageFromResponse(e));
|
||||
setError(extractErrorMessageFromResponse(e, intl));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -197,8 +242,8 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
|
||||
const text =
|
||||
previousStep.current === Step.Token
|
||||
? 'You have been subscribed on updates by email'
|
||||
: 'You are subscribed on updates by email';
|
||||
? intl.formatMessage(messages.haveSubscribed)
|
||||
: intl.formatMessage(messages.subscribed);
|
||||
|
||||
return (
|
||||
<div className={b('comment-form__subscribe-by-email', { mods: { subscribed: true } })}>
|
||||
@@ -210,7 +255,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
theme={theme}
|
||||
onClick={handleUnsubscribe}
|
||||
>
|
||||
Unsubscribe
|
||||
<FormattedMessage id="subscribeByEmail.unsubscribe" defaultMessage="Unsubscribe" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -225,7 +270,10 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
|
||||
return (
|
||||
<div className={b('comment-form__subscribe-by-email', { mods: { unsubscribed: true } })}>
|
||||
You have been unsubscribed by email to updates
|
||||
<FormattedMessage
|
||||
id="subscribeByEmail.have-been-unsubscribed"
|
||||
defaultMessage="You have been unsubscribed by email to updates"
|
||||
/>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="middle"
|
||||
@@ -233,18 +281,19 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
theme={theme}
|
||||
onClick={() => setStep(Step.Close)}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="subscribeByEmail.close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe';
|
||||
const buttonLabel =
|
||||
step === Step.Email ? intl.formatMessage(messages.submit) : intl.formatMessage(messages.subscribe);
|
||||
|
||||
return (
|
||||
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
|
||||
{step === Step.Email && renderEmailPart(loading, emailAddress, handleChangeEmail, emailAddressRef)}
|
||||
{step === Step.Token && renderTokenPart(loading, token, handleChangeToken, setEmailStep)}
|
||||
{step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail, emailAddressRef)}
|
||||
{step === Step.Token && renderTokenPart(loading, intl, token, handleChangeToken, setEmailStep)}
|
||||
{error !== null && (
|
||||
<div className="comment-form__subscribe-by-email__error" role="alert">
|
||||
{error}
|
||||
@@ -265,14 +314,17 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
|
||||
export const SubscribeByEmail: FunctionComponent = () => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const user = useSelector<StoreState, User | null>(({ user }) => user);
|
||||
const isAnonymous = isUserAnonymous(user);
|
||||
const buttonTitle = isAnonymous ? 'Available only for registered users' : 'Subscribe by Email';
|
||||
const buttonTitle = isAnonymous
|
||||
? intl.formatMessage(messages.onlyRegisteredUsers)
|
||||
: intl.formatMessage(messages.subscribeByEmail);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
mix="comment-form__email-dropdown"
|
||||
title="Email"
|
||||
title={intl.formatMessage(messages.email)}
|
||||
theme={theme}
|
||||
disabled={isAnonymous}
|
||||
buttonTitle={buttonTitle}
|
||||
|
||||
+12
@@ -1,6 +1,7 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { shallow } from 'enzyme';
|
||||
import enMessages from '../../../locales/en.json';
|
||||
|
||||
import { SubscribeByRSS, createSubscribeUrl } from './';
|
||||
|
||||
@@ -8,6 +9,16 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(fn => fn({ theme: 'light' })),
|
||||
}));
|
||||
|
||||
jest.mock('react-intl', () => {
|
||||
// Require the original module to not be mocked...
|
||||
const originalModule = jest.requireActual('react-intl');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
useIntl: () => originalModule.createIntl({ locale: `en`, messages: enMessages }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<SubscribeByRSS/>', () => {
|
||||
let wrapper: ReturnType<typeof shallow>;
|
||||
|
||||
@@ -16,6 +27,7 @@ describe('<SubscribeByRSS/>', () => {
|
||||
});
|
||||
|
||||
it('should be render links in dropdown', () => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
|
||||
+30
-5
@@ -1,6 +1,7 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, FunctionComponent } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import useTheme from '@app/hooks/useTheme';
|
||||
import { siteId, url } from '@app/common/settings';
|
||||
@@ -10,22 +11,46 @@ import { Dropdown, DropdownItem } from '@app/components/dropdown';
|
||||
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams: string = '') =>
|
||||
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
|
||||
|
||||
const messages = defineMessages({
|
||||
thread: {
|
||||
id: 'subscribeByRSS.thread',
|
||||
defaultMessage: 'Thread',
|
||||
},
|
||||
site: {
|
||||
id: 'subscribeByRSS.site',
|
||||
defaultMessage: 'Site',
|
||||
},
|
||||
replies: {
|
||||
id: 'subscribeByRSS.replies',
|
||||
defaultMessage: 'Replies',
|
||||
},
|
||||
buttonTitle: {
|
||||
id: 'subscribeByRSS.button-title',
|
||||
defaultMessage: 'Subscribe by RSS',
|
||||
},
|
||||
title: {
|
||||
id: 'subscribeByRSS.title',
|
||||
defaultMessage: 'RSS',
|
||||
},
|
||||
});
|
||||
|
||||
export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ userId }) => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const items: Array<[string, string]> = useMemo(
|
||||
() => [
|
||||
[createSubscribeUrl('post'), 'Thread'],
|
||||
[createSubscribeUrl('site', `&user=${userId}`), 'Site'],
|
||||
[createSubscribeUrl('reply', `&url=${url}`), 'Replies'],
|
||||
[createSubscribeUrl('post'), intl.formatMessage(messages.thread)],
|
||||
[createSubscribeUrl('site', `&user=${userId}`), intl.formatMessage(messages.site)],
|
||||
[createSubscribeUrl('reply', `&url=${url}`), intl.formatMessage(messages.replies)],
|
||||
],
|
||||
[userId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title="RSS"
|
||||
title={intl.formatMessage(messages.title)}
|
||||
titleClass="comment-form__rss-dropdown__title"
|
||||
buttonTitle="Subscribe by RSS"
|
||||
buttonTitle={intl.formatMessage(messages.buttonTitle)}
|
||||
mix="comment-form__rss-dropdown"
|
||||
theme={theme}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { StaticStore } from '@app/common/static_store';
|
||||
import { CommentForm, Props } from './comment-form';
|
||||
import { SubscribeByEmail } from './__subscribe-by-email';
|
||||
|
||||
const DEFAULT_PROPS: Readonly<Props> = {
|
||||
const DEFAULT_PROPS: Readonly<Omit<Props, 'intl'>> = {
|
||||
mode: 'main',
|
||||
theme: 'light',
|
||||
onSubmit: () => Promise.resolve(),
|
||||
@@ -16,9 +16,15 @@ const DEFAULT_PROPS: Readonly<Props> = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
const intl = {
|
||||
formatMessage() {
|
||||
return '';
|
||||
},
|
||||
} as any;
|
||||
|
||||
describe('<CommentForm />', () => {
|
||||
it('should render without control panel, preview button, and rss links in "simple view" mode', () => {
|
||||
const props = { ...DEFAULT_PROPS, simpleView: true };
|
||||
const props = { ...DEFAULT_PROPS, simpleView: true, intl };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(wrapper.exists('.comment-form__control-panel')).toEqual(false);
|
||||
@@ -29,7 +35,7 @@ describe('<CommentForm />', () => {
|
||||
it('should be rendered with email subscription button', () => {
|
||||
StaticStore.config.email_notifications = true;
|
||||
|
||||
const props = { ...DEFAULT_PROPS, user };
|
||||
const props = { ...DEFAULT_PROPS, user, intl };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(wrapper.exists(SubscribeByEmail)).toEqual(true);
|
||||
@@ -38,7 +44,7 @@ describe('<CommentForm />', () => {
|
||||
it('should be rendered without email subscription button when email_notifications disabled', () => {
|
||||
StaticStore.config.email_notifications = false;
|
||||
|
||||
const props = { ...DEFAULT_PROPS, user };
|
||||
const props = { ...DEFAULT_PROPS, user, intl };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(wrapper.exists(SubscribeByEmail)).toEqual(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component, createRef, Fragment } from 'preact';
|
||||
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
|
||||
import b, { Mix } from 'bem-react-helper';
|
||||
|
||||
import { User, Theme, Image, ApiError } from '@app/common/types';
|
||||
@@ -34,6 +35,7 @@ export interface Props {
|
||||
/** action on cancel. optional as root input has no cancel option */
|
||||
onCancel?: () => void;
|
||||
uploadImage?: (image: File) => Promise<Image>;
|
||||
intl: IntlShape;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -51,14 +53,39 @@ interface State {
|
||||
buttonText: null | string;
|
||||
}
|
||||
|
||||
const Labels = {
|
||||
main: 'Send',
|
||||
edit: 'Save',
|
||||
reply: 'Reply',
|
||||
};
|
||||
|
||||
const ImageMimeRegex = /image\//i;
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'commentForm.input-placeholder',
|
||||
defaultMessage: 'Your comment here',
|
||||
},
|
||||
uploadFileFail: {
|
||||
id: 'commentForm.upload-file-fail',
|
||||
defaultMessage: '{fileName} upload failed with "{errorMessage}"',
|
||||
},
|
||||
uploading: {
|
||||
id: 'commentForm.uploading',
|
||||
defaultMessage: 'Uploading...',
|
||||
},
|
||||
uploadingFile: {
|
||||
id: 'commentForm.uploading-file',
|
||||
defaultMessage: 'uploading {fileName}...',
|
||||
},
|
||||
exceededSize: {
|
||||
id: 'commentForm.exceeded-size',
|
||||
defaultMessage: '{fileName} exceeds size limit of {maxImageSize}',
|
||||
},
|
||||
newComment: {
|
||||
id: 'commentForm.new-comment',
|
||||
defaultMessage: 'New comment',
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'commentForm.unexpected-error',
|
||||
defaultMessage: 'Something went wrong. Please try again a bit later.',
|
||||
},
|
||||
});
|
||||
|
||||
export class CommentForm extends Component<Props, State> {
|
||||
/** reference to textarea element */
|
||||
textAreaRef = createRef<TextareaAutosize>();
|
||||
@@ -165,8 +192,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
this.setState({ preview: null, text: '' });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
const errorMessage = extractErrorMessageFromResponse(e);
|
||||
const errorMessage = extractErrorMessageFromResponse(e, this.props.intl);
|
||||
this.setState({ isErrorShown: true, errorMessage });
|
||||
})
|
||||
.finally(() => this.setState({ isDisabled: false }));
|
||||
@@ -228,18 +254,20 @@ export class CommentForm extends Component<Props, State> {
|
||||
|
||||
/** wrapper with error handling for props.uploadImage */
|
||||
uploadImage(file: File): Promise<Image | Error> {
|
||||
return this.props.uploadImage!(file).catch(
|
||||
(e: ApiError | string) =>
|
||||
new Error(
|
||||
typeof e === 'string'
|
||||
? `${file.name} upload failed with "${e}"`
|
||||
: `${file.name} upload failed with "${e.error}"`
|
||||
)
|
||||
);
|
||||
const intl = this.props.intl;
|
||||
return this.props.uploadImage!(file).catch((e: ApiError | string) => {
|
||||
return new Error(
|
||||
intl.formatMessage(messages.uploadFileFail, {
|
||||
fileName: file.name,
|
||||
errorMessage: extractErrorMessageFromResponse(e, this.props.intl),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** performs upload process */
|
||||
async uploadImages(files: File[]) {
|
||||
const intl = this.props.intl;
|
||||
if (!this.props.uploadImage) return;
|
||||
if (!this.textAreaRef.current) return;
|
||||
|
||||
@@ -255,7 +283,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
errorMessage: null,
|
||||
isErrorShown: false,
|
||||
isDisabled: true,
|
||||
buttonText: 'Uploading...',
|
||||
buttonText: intl.formatMessage(messages.uploading),
|
||||
});
|
||||
|
||||
// fallback for ie < 9
|
||||
@@ -266,7 +294,12 @@ export class CommentForm extends Component<Props, State> {
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
this.appendError(
|
||||
intl.formatMessage(messages.exceededSize, {
|
||||
fileName: file.name,
|
||||
maxImageSize: maxImageSizeString,
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -294,7 +327,9 @@ export class CommentForm extends Component<Props, State> {
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
|
||||
const uploadPlaceholder = `${placeholderStart}![${intl.formatMessage(messages.uploadingFile, {
|
||||
fileName: file.name,
|
||||
})}]()`;
|
||||
const uploadPlaceholderLength = uploadPlaceholder.length;
|
||||
const selection = this.textAreaRef.current.getSelection();
|
||||
/** saved selection in case of error */
|
||||
@@ -309,7 +344,12 @@ export class CommentForm extends Component<Props, State> {
|
||||
};
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
this.appendError(
|
||||
intl.formatMessage(messages.exceededSize, {
|
||||
fileName: file.name,
|
||||
maxImageSize: maxImageSizeString,
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -343,8 +383,14 @@ export class CommentForm extends Component<Props, State> {
|
||||
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
|
||||
const charactersLeft = maxLength - text.length;
|
||||
errorMessage = props.errorMessage || errorMessage;
|
||||
const Labels = {
|
||||
main: <FormattedMessage id="commentForm.send" defaultMessage="Send" />,
|
||||
edit: <FormattedMessage id="commentForm.save" defaultMessage="Save" />,
|
||||
reply: <FormattedMessage id="commentForm.reply" defaultMessage="Reply" />,
|
||||
};
|
||||
const label = buttonText || Labels[props.mode || 'main'];
|
||||
|
||||
const intl = this.props.intl;
|
||||
const placeholderMessage = intl.formatMessage(messages.placeholder);
|
||||
return (
|
||||
<form
|
||||
className={b('comment-form', {
|
||||
@@ -356,13 +402,14 @@ export class CommentForm extends Component<Props, State> {
|
||||
mix: props.mix,
|
||||
})}
|
||||
onSubmit={this.send}
|
||||
aria-label="New comment"
|
||||
aria-label={intl.formatMessage(messages.newComment)}
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
{!props.simpleView && (
|
||||
<div className="comment-form__control-panel">
|
||||
<MarkdownToolbar
|
||||
intl={intl}
|
||||
allowUpload={Boolean(this.props.uploadImage)}
|
||||
uploadImages={this.uploadImages}
|
||||
textareaId={this.textareaId}
|
||||
@@ -376,7 +423,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
onPaste={this.onPaste}
|
||||
ref={this.textAreaRef}
|
||||
className="comment-form__field"
|
||||
placeholder="Your comment here"
|
||||
placeholder={placeholderMessage}
|
||||
value={text}
|
||||
maxLength={maxLength}
|
||||
onInput={this.onInput}
|
||||
@@ -390,7 +437,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
</div>
|
||||
|
||||
{(isErrorShown || !!errorMessage) &&
|
||||
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
|
||||
(errorMessage || intl.formatMessage(messages.unexpectedError)).split('\n').map(e => (
|
||||
<p className="comment-form__error" role="alert" key={e}>
|
||||
{e}
|
||||
</p>
|
||||
@@ -406,7 +453,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
disabled={isDisabled}
|
||||
onClick={this.getPreview}
|
||||
>
|
||||
Preview
|
||||
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
|
||||
</Button>
|
||||
)}
|
||||
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
|
||||
@@ -416,18 +463,24 @@ export class CommentForm extends Component<Props, State> {
|
||||
{!props.simpleView && props.mode === 'main' && (
|
||||
<div className="comment-form__rss">
|
||||
<div className="comment-form__markdown">
|
||||
Styling with{' '}
|
||||
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
|
||||
Markdown
|
||||
</a>
|
||||
{' is supported'}
|
||||
<FormattedMessage
|
||||
id="commentForm.notice-about-styling"
|
||||
defaultMessage="Styling with <a>Markdown</a> is supported"
|
||||
values={{
|
||||
a: (title: string) => (
|
||||
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
|
||||
{title}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{'Subscribe by '}
|
||||
<FormattedMessage id="commentForm.subscribe-by" defaultMessage="Subscribe by" />{' '}
|
||||
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
|
||||
{StaticStore.config.email_notifications && (
|
||||
<Fragment>
|
||||
{' or '}
|
||||
<SubscribeByEmail />
|
||||
{' '}
|
||||
<FormattedMessage id="commentForm.subscribe-or" defaultMessage="or" /> <SubscribeByEmail />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
@@ -439,7 +492,9 @@ export class CommentForm extends Component<Props, State> {
|
||||
!!preview && (
|
||||
<div className="comment-form__preview-wrapper">
|
||||
<div
|
||||
className={b('comment-form__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
className={b('comment-form__preview', {
|
||||
mix: b('raw-content', {}, { theme: props.theme }),
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component } from 'preact';
|
||||
import '@github/markdown-toolbar-element';
|
||||
import { defineMessages, IntlShape } from 'react-intl';
|
||||
import BoldIcon from './markdown-toolbar-icons/bold-icon';
|
||||
import HeaderIcon from './markdown-toolbar-icons/header-icon';
|
||||
import ItalicIcon from './markdown-toolbar-icons/italic-icon';
|
||||
@@ -12,6 +13,7 @@ import UnorderedListIcon from './markdown-toolbar-icons/unordered-list-icon';
|
||||
import OrderedListIcon from './markdown-toolbar-icons/ordered-list-icon';
|
||||
|
||||
interface Props {
|
||||
intl: IntlShape;
|
||||
textareaId: string;
|
||||
uploadImages: (files: File[]) => Promise<void>;
|
||||
allowUpload: boolean;
|
||||
@@ -26,15 +28,44 @@ interface FileInputEvent extends Event {
|
||||
readonly currentTarget: FileEventTarget | null;
|
||||
}
|
||||
|
||||
const boldLabel = 'Add bold text <cmd-b>';
|
||||
const headerLabel = 'Add header text';
|
||||
const italicLabel = 'Add italic text <cmd-i>';
|
||||
const quoteLabel = 'Insert a quote';
|
||||
const codeLabel = 'Insert a code';
|
||||
const linkLabel = 'Add a link <cmd-k>';
|
||||
const unorderedListLabel = 'Add a bulleted list';
|
||||
const orderedListLabel = 'Add a numbered list';
|
||||
const attachImageLabel = 'Attach the image, drag & drop or paste from clipboard';
|
||||
const messages = defineMessages({
|
||||
bold: {
|
||||
id: 'toolbar.bold',
|
||||
defaultMessage: 'Add bold text <cmd-b>',
|
||||
},
|
||||
header: {
|
||||
id: 'toolbar.header',
|
||||
defaultMessage: 'Add header text',
|
||||
},
|
||||
italic: {
|
||||
id: 'toolbar.italic',
|
||||
defaultMessage: 'Add italic text <cmd-i>',
|
||||
},
|
||||
quote: {
|
||||
id: 'toolbar.quote',
|
||||
defaultMessage: 'Insert a quote',
|
||||
},
|
||||
code: {
|
||||
id: 'toolbar.code',
|
||||
defaultMessage: 'Insert a code',
|
||||
},
|
||||
link: {
|
||||
id: 'toolbar.link',
|
||||
defaultMessage: 'Add a link <cmd-k>',
|
||||
},
|
||||
unorderedList: {
|
||||
id: 'toolbar.unordered-list',
|
||||
defaultMessage: 'Add a bulleted list',
|
||||
},
|
||||
orderedList: {
|
||||
id: 'toolbar.ordered-list',
|
||||
defaultMessage: 'Add a numbered list',
|
||||
},
|
||||
attachImage: {
|
||||
id: 'toolbar.attach-image',
|
||||
defaultMessage: 'Attach the image, drag & drop or paste from clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
export default class MarkdownToolbar extends Component<Props> {
|
||||
constructor(props: Props) {
|
||||
@@ -49,6 +80,18 @@ export default class MarkdownToolbar extends Component<Props> {
|
||||
currentTarget.value = null;
|
||||
}
|
||||
render(props: Props) {
|
||||
const intl = props.intl;
|
||||
|
||||
const boldLabel = intl.formatMessage(messages.bold);
|
||||
const headerLabel = intl.formatMessage(messages.header);
|
||||
const italicLabel = intl.formatMessage(messages.italic);
|
||||
const quoteLabel = intl.formatMessage(messages.quote);
|
||||
const codeLabel = intl.formatMessage(messages.code);
|
||||
const linkLabel = intl.formatMessage(messages.link);
|
||||
const unorderedListLabel = intl.formatMessage(messages.unorderedList);
|
||||
const orderedListLabel = intl.formatMessage(messages.orderedList);
|
||||
const attachImageLabel = intl.formatMessage(messages.attachImage);
|
||||
|
||||
return (
|
||||
<markdown-toolbar className="comment-form__toolbar" for={props.textareaId}>
|
||||
<div className="comment-form__toolbar-group">
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { mount as enzymeMount } from 'enzyme';
|
||||
import { Props, Comment } from './comment';
|
||||
import { User, Comment as CommentType, PostInfo } from '@app/common/types';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import enMessages from '../../locales/en.json';
|
||||
|
||||
const mount = (component: any) =>
|
||||
enzymeMount(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
{component}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const DefaultProps: Partial<Props> = {
|
||||
CommentForm: null,
|
||||
@@ -19,6 +28,7 @@ const DefaultProps: Partial<Props> = {
|
||||
id: 'someone',
|
||||
picture: 'somepicture-url',
|
||||
},
|
||||
time: new Date().toString(),
|
||||
locator: {
|
||||
url: 'somelocatorurl',
|
||||
site: 'remark',
|
||||
@@ -35,7 +45,7 @@ describe('<Comment />', () => {
|
||||
describe('voting', () => {
|
||||
it('should be disabled for an anonymous user', () => {
|
||||
const props = { ...DefaultProps, user: { id: 'anonymous_1' } } as Props;
|
||||
const wrapper = shallow(<Comment {...props} />);
|
||||
const wrapper = mount(<Comment {...props} />);
|
||||
const voteButtons = wrapper.find('.comment__vote');
|
||||
|
||||
expect(voteButtons.length).toEqual(2);
|
||||
@@ -50,7 +60,7 @@ describe('<Comment />', () => {
|
||||
StaticStore.config.anon_vote = true;
|
||||
|
||||
const props = { ...DefaultProps, user: { id: 'anonymous_1' } } as Props;
|
||||
const wrapper = shallow(<Comment {...props} />);
|
||||
const wrapper = mount(<Comment {...props} />);
|
||||
const voteButtons = wrapper.find('.comment__vote');
|
||||
|
||||
expect(voteButtons.length).toEqual(2);
|
||||
@@ -271,10 +281,14 @@ describe('<Comment />', () => {
|
||||
repliesCount: 0,
|
||||
};
|
||||
StaticStore.config.edit_duration = 300;
|
||||
const WrappedComponent = (props: Props) => (
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<Comment {...props} />
|
||||
</IntlProvider>
|
||||
);
|
||||
const component = enzymeMount(<WrappedComponent {...(props as Props)} />);
|
||||
|
||||
const component = shallow(<Comment {...(props as Props)} />);
|
||||
|
||||
expect((component.state('editDeadline') as Date).getTime()).toBe(
|
||||
expect((component.find(`Comment`).state('editDeadline') as Date).getTime()).toBe(
|
||||
new Date(new Date(initTime).getTime() + 300 * 1000).getTime()
|
||||
);
|
||||
|
||||
@@ -282,7 +296,7 @@ describe('<Comment />', () => {
|
||||
data: { ...props.data, time: changedTime },
|
||||
});
|
||||
|
||||
expect((component.state('editDeadline') as Date).getTime()).toBe(
|
||||
expect((component.find(`Comment`).state('editDeadline') as Date).getTime()).toBe(
|
||||
new Date(new Date(changedTime).getTime() + 300 * 1000).getTime()
|
||||
);
|
||||
});
|
||||
@@ -302,9 +316,9 @@ describe('<Comment />', () => {
|
||||
};
|
||||
StaticStore.config.edit_duration = 300;
|
||||
|
||||
const component = shallow(<Comment {...(props as Props)} />);
|
||||
const component = mount(<Comment {...(props as Props)} />);
|
||||
|
||||
expect(component.state('editDeadline')).toBe(null);
|
||||
expect(component.find('Comment').state('editDeadline')).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createElement, JSX, Component, createRef, ComponentType } from 'preact'
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } from '@app/common/constants';
|
||||
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants';
|
||||
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import debounce from '@app/utils/debounce';
|
||||
@@ -22,8 +22,74 @@ import Countdown from '@app/components/countdown';
|
||||
import { boundActions } from './connected-comment';
|
||||
import { getPreview, uploadImage } from '@app/common/api';
|
||||
import postMessage from '@app/utils/postMessage';
|
||||
import { FormattedMessage, useIntl, IntlShape, defineMessages } from 'react-intl';
|
||||
import { getVoteMessage, VoteMessagesTypes } from './getVoteMessage';
|
||||
import { getBlockingDurations } from './getBlockingDurations';
|
||||
|
||||
export type Props = {
|
||||
const messages = defineMessages({
|
||||
deleteMessage: {
|
||||
id: 'comment.delete-message',
|
||||
defaultMessage: 'Do you want to delete this comment?',
|
||||
},
|
||||
hideUserComments: {
|
||||
id: 'comment.hide-user-comment',
|
||||
defaultMessage: 'Do you want to hide comments of {userName}?',
|
||||
},
|
||||
pinComment: {
|
||||
id: 'comment.pin-comment',
|
||||
defaultMessage: 'Do you want to pin this comment?',
|
||||
},
|
||||
unpinComment: {
|
||||
id: 'comment.unpin-comment',
|
||||
defaultMessage: 'Do you want to unpin this comment?',
|
||||
},
|
||||
verifyUser: {
|
||||
id: 'comment.verify-user',
|
||||
defaultMessage: 'Do you want to verify {userName}?',
|
||||
},
|
||||
unverifyUser: {
|
||||
id: 'comment.unverify-user',
|
||||
defaultMessage: 'Do you want to unverify {userName}?',
|
||||
},
|
||||
blockUser: {
|
||||
id: 'comment.block-user',
|
||||
defaultMessage: 'Do you want to block {userName} {duration}?',
|
||||
},
|
||||
unblockUser: {
|
||||
id: 'comment.unblock-user',
|
||||
defaultMessage: 'Do you want to unblock this user?',
|
||||
},
|
||||
deletedComment: {
|
||||
id: 'comment.deleted-comment',
|
||||
defaultMessage: 'This comment was deleted',
|
||||
},
|
||||
controversy: {
|
||||
id: 'comment.controversy',
|
||||
defaultMessage: 'Controversy: {value}',
|
||||
},
|
||||
toggleVerification: {
|
||||
id: 'comment.toggle-verification',
|
||||
defaultMessage: 'Toggle verification',
|
||||
},
|
||||
verifiedUser: {
|
||||
id: 'comment.verified-user',
|
||||
defaultMessage: 'Verified user',
|
||||
},
|
||||
unverifiedUser: {
|
||||
id: 'comment.unverified-user',
|
||||
defaultMessage: 'Unverified user',
|
||||
},
|
||||
goToParent: {
|
||||
id: 'comment.go-to-parent',
|
||||
defaultMessage: 'Go to parent comment',
|
||||
},
|
||||
expiredTime: {
|
||||
id: 'comment.expired-time',
|
||||
defaultMessage: 'Editing time has expired.',
|
||||
},
|
||||
});
|
||||
|
||||
type PropsWithoutIntl = {
|
||||
user: User | null;
|
||||
CommentForm: ComponentType<CommentFormProps> | null;
|
||||
data: CommentType;
|
||||
@@ -52,6 +118,8 @@ export type Props = {
|
||||
uploadImage?: typeof uploadImage;
|
||||
} & Partial<typeof boundActions>;
|
||||
|
||||
export type Props = PropsWithoutIntl & { intl: IntlShape };
|
||||
|
||||
export interface State {
|
||||
renderDummy: boolean;
|
||||
isCopied: boolean;
|
||||
@@ -72,7 +140,7 @@ export interface State {
|
||||
initial: boolean;
|
||||
}
|
||||
|
||||
export class Comment extends Component<Props, State> {
|
||||
class Comment extends Component<Props, State> {
|
||||
votingPromise: Promise<unknown>;
|
||||
/** comment text node. Used in comment text copying */
|
||||
textNode = createRef<HTMLDivElement>();
|
||||
@@ -166,7 +234,8 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
togglePin = () => {
|
||||
const value = !this.props.data.pin;
|
||||
const promptMessage = `Do you want to ${value ? 'pin' : 'unpin'} this comment?`;
|
||||
const intl = this.props.intl;
|
||||
const promptMessage = value ? intl.formatMessage(messages.pinComment) : intl.formatMessage(messages.unpinComment);
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
this.props.setPinState!(this.props.data.id, value);
|
||||
@@ -176,7 +245,11 @@ export class Comment extends Component<Props, State> {
|
||||
toggleVerify = () => {
|
||||
const value = !this.props.data.user.verified;
|
||||
const userId = this.props.data.user.id;
|
||||
const promptMessage = `Do you want to ${value ? 'verify' : 'unverify'} ${this.props.data.user.name}?`;
|
||||
const intl = this.props.intl;
|
||||
const userName = this.props.data.user.name;
|
||||
const promptMessage = value
|
||||
? intl.formatMessage(messages.verifyUser, { userName })
|
||||
: intl.formatMessage(messages.unverifyUser, { userName });
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
this.props.setVerifyStatus!(userId, value);
|
||||
@@ -197,30 +270,34 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
blockUser = (ttl: BlockTTL) => {
|
||||
const { user } = this.props.data;
|
||||
|
||||
const block_duration = BLOCKING_DURATIONS.find(el => el.value === ttl);
|
||||
const blockingDurations = getBlockingDurations(this.props.intl);
|
||||
const blockDuration = blockingDurations.find(el => el.value === ttl);
|
||||
// blocking duration may be undefined if user hasn't selected anything
|
||||
// and ttl equals "Blocking period"
|
||||
if (!block_duration) return;
|
||||
if (!blockDuration) return;
|
||||
|
||||
const duration = block_duration.label;
|
||||
if (confirm(`Do you want to block ${user.name} ${duration.toLowerCase()}?`)) {
|
||||
const duration = blockDuration.label;
|
||||
const blockUser = this.props.intl.formatMessage(messages.blockUser, {
|
||||
userName: user.name,
|
||||
duration: duration.toLowerCase(),
|
||||
});
|
||||
if (confirm(blockUser)) {
|
||||
this.props.blockUser!(user.id, user.name, ttl);
|
||||
}
|
||||
};
|
||||
|
||||
onUnblockUserClick = () => {
|
||||
const { user } = this.props.data;
|
||||
const unblockUser = this.props.intl.formatMessage(messages.unblockUser);
|
||||
|
||||
const promptMessage = `Do you want to unblock this user?`;
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
if (confirm(unblockUser)) {
|
||||
this.props.unblockUser!(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
deleteComment = () => {
|
||||
if (confirm('Do you want to delete this comment?')) {
|
||||
const deleteComment = this.props.intl.formatMessage(messages.deleteMessage);
|
||||
if (confirm(deleteComment)) {
|
||||
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
|
||||
|
||||
this.props.removeComment!(this.props.data.id);
|
||||
@@ -228,7 +305,10 @@ export class Comment extends Component<Props, State> {
|
||||
};
|
||||
|
||||
hideUser = () => {
|
||||
if (!confirm(`Do you want to hide comments of ${this.props.data.user.name}?`)) return;
|
||||
const hideUserComment = this.props.intl.formatMessage(messages.hideUserComments, {
|
||||
userName: this.props.data.user.name,
|
||||
});
|
||||
if (!confirm(hideUserComment)) return;
|
||||
this.props.hideUser!(this.props.data.user);
|
||||
};
|
||||
|
||||
@@ -236,7 +316,7 @@ export class Comment extends Component<Props, State> {
|
||||
this.setState({
|
||||
scoreDelta: originalDelta,
|
||||
cachedScore: originalScore,
|
||||
voteErrorMessage: extractErrorMessageFromResponse(e),
|
||||
voteErrorMessage: extractErrorMessageFromResponse(e, this.props.intl),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -349,13 +429,16 @@ export class Comment extends Component<Props, State> {
|
||||
* returns reason for disabled downvoting
|
||||
*/
|
||||
getDownvoteDisabledReason = (): string | null => {
|
||||
if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page";
|
||||
if (this.props.post_info!.read_only) return "Can't vote on read-only topics";
|
||||
if (this.props.data.delete) return "Can't vote for deleted comment";
|
||||
if (this.isCurrentUser()) return "Can't vote for your own comment";
|
||||
if (StaticStore.config.positive_score && this.props.data.score < 1) return 'Only positive score allowed';
|
||||
if (this.isGuest()) return 'Sign in to vote';
|
||||
if (this.isAnonymous() && !StaticStore.config.anon_vote) return "Anonymous users can't vote";
|
||||
const intl = this.props.intl;
|
||||
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
|
||||
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
|
||||
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
|
||||
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
|
||||
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
|
||||
if (StaticStore.config.positive_score && this.props.data.score < 1)
|
||||
return getVoteMessage(VoteMessagesTypes.ONLY_POSITIVE, intl);
|
||||
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
|
||||
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -363,12 +446,14 @@ export class Comment extends Component<Props, State> {
|
||||
* returns reason for disabled upvoting
|
||||
*/
|
||||
getUpvoteDisabledReason = (): string | null => {
|
||||
if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page";
|
||||
if (this.props.post_info!.read_only) return "Can't vote on read-only topics";
|
||||
if (this.props.data.delete) return "Can't vote for deleted comment";
|
||||
if (this.isCurrentUser()) return "Can't vote for your own comment";
|
||||
if (this.isGuest()) return 'Sign in to vote';
|
||||
if (this.isAnonymous() && !StaticStore.config.anon_vote) return "Anonymous users can't vote";
|
||||
const intl = this.props.intl;
|
||||
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
|
||||
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
|
||||
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
|
||||
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
|
||||
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
|
||||
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
|
||||
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -388,17 +473,23 @@ export class Comment extends Component<Props, State> {
|
||||
if (isAdmin) {
|
||||
controls.push(
|
||||
this.state.isCopied ? (
|
||||
<span className="comment__control comment__control_view_inactive">Copied!</span>
|
||||
<span className="comment__control comment__control_view_inactive">
|
||||
<FormattedMessage id="comment.copied" defaultMessage="Copied!" />
|
||||
</span>
|
||||
) : (
|
||||
<Button kind="link" {...getHandleClickProps(this.copyComment)} mix="comment__control">
|
||||
Copy
|
||||
<FormattedMessage id="comment.copy" defaultMessage="Copy" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
controls.push(
|
||||
<Button kind="link" {...getHandleClickProps(this.togglePin)} mix="comment__control">
|
||||
{this.props.data.pin ? 'Unpin' : 'Pin'}
|
||||
{this.props.data.pin ? (
|
||||
<FormattedMessage id="comment.unpin" defaultMessage="Unpin" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.pin" defaultMessage="Pin" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -406,7 +497,7 @@ export class Comment extends Component<Props, State> {
|
||||
if (!isCurrentUser) {
|
||||
controls.push(
|
||||
<Button kind="link" {...getHandleClickProps(this.hideUser)} mix="comment__control">
|
||||
Hide
|
||||
<FormattedMessage id="comment.hide" defaultMessage="Hide" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -415,21 +506,20 @@ export class Comment extends Component<Props, State> {
|
||||
if (this.props.isUserBanned) {
|
||||
controls.push(
|
||||
<Button kind="link" {...getHandleClickProps(this.onUnblockUserClick)} mix="comment__control">
|
||||
Unblock
|
||||
<FormattedMessage id="comment.unblock" defaultMessage="Unblock" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const blockingDurations = getBlockingDurations(this.props.intl);
|
||||
if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) {
|
||||
controls.push(
|
||||
<span className="comment__control comment__control_select-label">
|
||||
Block
|
||||
<FormattedMessage id="comment.block" defaultMessage="Block" />
|
||||
<select className="comment__control_select" onBlur={this.onBlockUserClick} onChange={this.onBlockUserClick}>
|
||||
<option disabled selected value={undefined}>
|
||||
{' '}
|
||||
Blocking period{' '}
|
||||
<FormattedMessage id="comment.blocking-period" defaultMessage="Blocking period" />
|
||||
</option>
|
||||
{BLOCKING_DURATIONS.map(block => (
|
||||
{blockingDurations.map(block => (
|
||||
<option value={block.value}>{block.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -440,7 +530,7 @@ export class Comment extends Component<Props, State> {
|
||||
if (!this.props.data.delete) {
|
||||
controls.push(
|
||||
<Button kind="link" {...getHandleClickProps(this.deleteComment)} mix="comment__control">
|
||||
Delete
|
||||
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -464,22 +554,25 @@ export class Comment extends Component<Props, State> {
|
||||
const scoreSignEnabled = !StaticStore.config.positive_score;
|
||||
const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
|
||||
const commentControls = this.getCommentControls();
|
||||
|
||||
const intl = props.intl;
|
||||
const CommentForm = this.props.CommentForm;
|
||||
|
||||
/**
|
||||
* CommentType adapted for rendering
|
||||
*/
|
||||
|
||||
const o = {
|
||||
...props.data,
|
||||
controversyText: `Controversy: ${(props.data.controversy || 0).toFixed(2)}`,
|
||||
controversyText: intl.formatMessage(messages.controversy, {
|
||||
value: (props.data.controversy || 0).toFixed(2),
|
||||
}),
|
||||
text:
|
||||
props.view === 'preview'
|
||||
? getTextSnippet(props.data.text)
|
||||
: props.data.delete
|
||||
? 'This comment was deleted'
|
||||
? intl.formatMessage(messages.deletedComment)
|
||||
: props.data.text,
|
||||
time: formatTime(new Date(props.data.time)),
|
||||
time: new Date(props.data.time),
|
||||
orig: isEditing
|
||||
? props.data.orig &&
|
||||
props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, entity => {
|
||||
@@ -563,7 +656,7 @@ export class Comment extends Component<Props, State> {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const goToParentMessage = intl.formatMessage(messages.goToParent);
|
||||
return (
|
||||
<article
|
||||
className={b('comment', { mix: this.props.mix }, defaultMods)}
|
||||
@@ -594,35 +687,50 @@ export class Comment extends Component<Props, State> {
|
||||
{isAdmin && props.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(this.toggleVerify)}
|
||||
aria-label="Toggle verification"
|
||||
title={o.user.verified ? 'Verified user' : 'Unverified user'}
|
||||
aria-label={intl.formatMessage(messages.toggleVerification)}
|
||||
title={
|
||||
o.user.verified
|
||||
? intl.formatMessage(messages.verifiedUser)
|
||||
: intl.formatMessage(messages.unverifiedUser)
|
||||
}
|
||||
className={b('comment__verification', {}, { active: o.user.verified, clickable: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
|
||||
<span title="Verified user" className={b('comment__verification', {}, { active: true })} />
|
||||
<span
|
||||
title={intl.formatMessage(messages.verifiedUser)}
|
||||
className={b('comment__verification', {}, { active: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
|
||||
{o.time}
|
||||
<FormatTime time={o.time} />
|
||||
</a>
|
||||
|
||||
{!!props.level && props.level > 0 && props.view === 'main' && (
|
||||
<a
|
||||
className="comment__link-to-parent"
|
||||
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
|
||||
aria-label="Go to parent comment"
|
||||
title="Go to parent comment"
|
||||
aria-label={goToParentMessage}
|
||||
title={goToParentMessage}
|
||||
onClick={e => this.scrollToParent(e)}
|
||||
>
|
||||
{' '}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{props.isUserBanned && props.view !== 'user' && <span className="comment__status">Blocked</span>}
|
||||
{props.isUserBanned && props.view !== 'user' && (
|
||||
<span className="comment__status">
|
||||
<FormattedMessage id="comment.blocked-user" defaultMessage="Blocked" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isAdmin && !props.isUserBanned && props.data.delete && <span className="comment__status">Deleted</span>}
|
||||
{isAdmin && !props.isUserBanned && props.data.delete && (
|
||||
<span className="comment__status">
|
||||
<FormattedMessage id="comment.deleted-user" defaultMessage="Deleted" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={b('comment__score', {}, { view: o.score.view })}>
|
||||
<span
|
||||
@@ -660,7 +768,11 @@ export class Comment extends Component<Props, State> {
|
||||
<div className="comment__body">
|
||||
{!!state.voteErrorMessage && (
|
||||
<div className="voting__error" role="alert">
|
||||
Voting error: {state.voteErrorMessage}
|
||||
<FormattedMessage
|
||||
id="comment.vote-error"
|
||||
defaultMessage="Voting error: {voteErrorMessage}"
|
||||
values={{ voteErrorMessage: state.voteErrorMessage }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -676,7 +788,11 @@ export class Comment extends Component<Props, State> {
|
||||
<div className="comment__actions">
|
||||
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
|
||||
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
{isReplying ? (
|
||||
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.reply" defaultMessage="Reply" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!props.data.delete &&
|
||||
@@ -690,7 +806,11 @@ export class Comment extends Component<Props, State> {
|
||||
{...getHandleClickProps(this.toggleEditing)}
|
||||
mix={['comment__action', 'comment__action_type_edit']}
|
||||
>
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
{isEditing ? (
|
||||
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.edit" defaultMessage="Edit" />
|
||||
)}
|
||||
</Button>,
|
||||
!isAdmin && (
|
||||
<Button
|
||||
@@ -698,7 +818,7 @@ export class Comment extends Component<Props, State> {
|
||||
{...getHandleClickProps(this.deleteComment)}
|
||||
mix={['comment__action', 'comment__action_type_delete']}
|
||||
>
|
||||
Delete
|
||||
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
),
|
||||
state.editDeadline && (
|
||||
@@ -721,6 +841,7 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
{CommentForm && isReplying && props.view === 'main' && (
|
||||
<CommentForm
|
||||
intl={this.props.intl}
|
||||
user={props.user}
|
||||
theme={props.theme}
|
||||
value=""
|
||||
@@ -737,6 +858,7 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
{CommentForm && isEditing && props.view === 'main' && (
|
||||
<CommentForm
|
||||
intl={this.props.intl}
|
||||
user={props.user}
|
||||
theme={props.theme}
|
||||
value={o.orig}
|
||||
@@ -745,7 +867,7 @@ export class Comment extends Component<Props, State> {
|
||||
onSubmit={(text, _title) => this.updateComment(props.data.id, text)}
|
||||
onCancel={this.toggleEditing}
|
||||
getPreview={this.props.getPreview!}
|
||||
errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined}
|
||||
errorMessage={state.editDeadline === null ? intl.formatMessage(messages.expiredTime) : undefined}
|
||||
autofocus={true}
|
||||
uploadImage={uploadImageHandler}
|
||||
simpleView={StaticStore.config.simple_view}
|
||||
@@ -767,13 +889,23 @@ function getTextSnippet(html: string) {
|
||||
return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet;
|
||||
}
|
||||
|
||||
function formatTime(time: Date) {
|
||||
// 'ru-RU' adds a dot as a separator
|
||||
const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
|
||||
// do it manually because Intl API doesn't add leading zeros to hours; idk why
|
||||
const hours = `0${time.getHours()}`.slice(-2);
|
||||
const mins = `0${time.getMinutes()}`.slice(-2);
|
||||
|
||||
return `${date} at ${hours}:${mins}`;
|
||||
function FormatTime({ time }: { time: Date }) {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="comment.time"
|
||||
defaultMessage="{day} at {time}"
|
||||
values={{
|
||||
day: intl.formatDate(time),
|
||||
time: intl.formatTime(time),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CommentWithIntl = (props: PropsWithoutIntl) => {
|
||||
const intl = useIntl();
|
||||
return <Comment intl={intl} {...props} />;
|
||||
};
|
||||
|
||||
export { CommentWithIntl as Comment };
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BlockingDuration } from '@app/common/types';
|
||||
import { IntlShape, defineMessages } from 'react-intl';
|
||||
|
||||
const blockingMessages = defineMessages({
|
||||
permanently: {
|
||||
id: 'blockingDuration.permanently',
|
||||
defaultMessage: 'Permanently',
|
||||
},
|
||||
month: {
|
||||
id: 'blockingDuration.month',
|
||||
defaultMessage: 'For a month',
|
||||
},
|
||||
week: {
|
||||
id: 'blockingDuration.week',
|
||||
defaultMessage: 'For a week',
|
||||
},
|
||||
day: {
|
||||
id: 'blockingDuration.day',
|
||||
defaultMessage: 'For a day',
|
||||
},
|
||||
});
|
||||
|
||||
export function getBlockingDurations(intl: IntlShape): BlockingDuration[] {
|
||||
return [
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.permanently),
|
||||
value: 'permanently',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.month),
|
||||
value: '43200m',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.week),
|
||||
value: '10080m',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.day),
|
||||
value: '1440m',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IntlShape, defineMessages } from 'react-intl';
|
||||
|
||||
const voteMessages = defineMessages({
|
||||
ownComment: {
|
||||
id: 'vote.own-comment',
|
||||
defaultMessage: `Can't vote for your own comment`,
|
||||
},
|
||||
guest: {
|
||||
id: 'vote.guest',
|
||||
defaultMessage: 'Sign in to vote',
|
||||
},
|
||||
onlyPostPage: {
|
||||
id: 'vote.only-post-page',
|
||||
defaultMessage: `Voting allowed only on post's page`,
|
||||
},
|
||||
readonly: {
|
||||
id: 'vote.readonly',
|
||||
defaultMessage: `Can't vote on read-only topics`,
|
||||
},
|
||||
deleted: {
|
||||
id: 'vote.deleted',
|
||||
defaultMessage: `Can't vote for deleted comment`,
|
||||
},
|
||||
anonymous: {
|
||||
id: 'vote.anonymous',
|
||||
defaultMessage: `Anonymous users can't vote`,
|
||||
},
|
||||
onlyPositive: {
|
||||
id: 'vote.only-positive',
|
||||
defaultMessage: `Only positive score allowed`,
|
||||
},
|
||||
});
|
||||
|
||||
export enum VoteMessagesTypes {
|
||||
OWN_COMMENT,
|
||||
GUEST,
|
||||
ONLY_POST_PAGE,
|
||||
READONLY,
|
||||
DELETED,
|
||||
ANONYMOUS,
|
||||
ONLY_POSITIVE,
|
||||
}
|
||||
|
||||
export function getVoteMessage(type: VoteMessagesTypes, intl: IntlShape) {
|
||||
const messages = {
|
||||
[VoteMessagesTypes.OWN_COMMENT]: intl.formatMessage(voteMessages.ownComment),
|
||||
[VoteMessagesTypes.GUEST]: intl.formatMessage(voteMessages.guest),
|
||||
[VoteMessagesTypes.ONLY_POST_PAGE]: intl.formatMessage(voteMessages.onlyPostPage),
|
||||
[VoteMessagesTypes.READONLY]: intl.formatMessage(voteMessages.readonly),
|
||||
[VoteMessagesTypes.DELETED]: intl.formatMessage(voteMessages.deleted),
|
||||
[VoteMessagesTypes.ANONYMOUS]: intl.formatMessage(voteMessages.anonymous),
|
||||
[VoteMessagesTypes.ONLY_POSITIVE]: intl.formatMessage(voteMessages.onlyPositive),
|
||||
};
|
||||
return messages[type];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createElement, Component, FunctionComponent } from 'preact';
|
||||
import { useSelector } from 'react-redux';
|
||||
import b from 'bem-react-helper';
|
||||
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import { User, Sorting, AuthProvider } from '@app/common/types';
|
||||
import {
|
||||
@@ -79,7 +80,7 @@ const boundActions = bindActions({
|
||||
updateComment,
|
||||
});
|
||||
|
||||
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions;
|
||||
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions & { intl: IntlShape };
|
||||
|
||||
interface State {
|
||||
isLoaded: boolean;
|
||||
@@ -88,6 +89,13 @@ interface State {
|
||||
wasSomeoneUnblocked: boolean;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
pinnedComments: {
|
||||
id: `root.pinned-comments`,
|
||||
defaultMessage: 'Pinned comments',
|
||||
},
|
||||
});
|
||||
|
||||
/** main component fr main comments widget */
|
||||
export class Root extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
@@ -243,6 +251,7 @@ export class Root extends Component<Props, State> {
|
||||
<div className="root__main">
|
||||
{!isGuest && !isCommentsDisabled && (
|
||||
<CommentForm
|
||||
intl={this.props.intl}
|
||||
theme={props.theme}
|
||||
mix="root__input"
|
||||
mode="main"
|
||||
@@ -255,10 +264,15 @@ export class Root extends Component<Props, State> {
|
||||
)}
|
||||
|
||||
{this.props.pinnedComments.length > 0 && (
|
||||
<div className="root__pinned-comments" role="region" aria-label="Pinned comments">
|
||||
<div
|
||||
className="root__pinned-comments"
|
||||
role="region"
|
||||
aria-label={this.props.intl.formatMessage(messages.pinnedComments)}
|
||||
>
|
||||
{this.props.pinnedComments.map(comment => (
|
||||
<Comment
|
||||
CommentForm={CommentForm}
|
||||
intl={this.props.intl}
|
||||
key={`pinned-comment-${comment.id}`}
|
||||
view="pinned"
|
||||
data={comment}
|
||||
@@ -287,7 +301,7 @@ export class Root extends Component<Props, State> {
|
||||
|
||||
{commentsShown < this.props.topComments.length && IS_MOBILE && (
|
||||
<Button kind="primary" size="middle" mix="root__show-more" onClick={this.showMore}>
|
||||
Show more
|
||||
<FormattedMessage id="root.show-more" defaultMessage="Show more" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -304,6 +318,7 @@ export class Root extends Component<Props, State> {
|
||||
{this.props.isSettingsVisible && (
|
||||
<div className="root__main">
|
||||
<Settings
|
||||
intl={this.props.intl}
|
||||
user={this.props.user}
|
||||
hiddenUsers={this.props.hiddenUsers}
|
||||
blockedUsers={this.props.blockedUsers}
|
||||
@@ -317,10 +332,17 @@ export class Root extends Component<Props, State> {
|
||||
)}
|
||||
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
Powered by{' '}
|
||||
<a href="https://remark42.com/" className="root__copyright-link">
|
||||
Remark42
|
||||
</a>
|
||||
<FormattedMessage
|
||||
id="root.powered-by"
|
||||
defaultMessage="Powered by <a>Remark42</a>"
|
||||
values={{
|
||||
a: (title: string) => (
|
||||
<a class="root__copyright-link" href="https://remark42.com/">
|
||||
{title}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,5 +354,6 @@ export class Root extends Component<Props, State> {
|
||||
export const ConnectedRoot: FunctionComponent = () => {
|
||||
const props = useSelector(mapStateToProps);
|
||||
const actions = useActions(boundActions);
|
||||
return <Root {...props} {...actions} />;
|
||||
const intl = useIntl();
|
||||
return <Root {...props} {...actions} intl={intl} />;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@ import b from 'bem-react-helper';
|
||||
import { User, BlockedUser, Theme, BlockTTL } from '@app/common/types';
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
import { StoreState } from '@app/store';
|
||||
import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
intl: IntlShape;
|
||||
user: StoreState['user'];
|
||||
blockedUsers: BlockedUser[];
|
||||
hiddenUsers: StoreState['hiddenUsers'];
|
||||
@@ -29,6 +31,25 @@ interface State {
|
||||
unhiddenUsers: User['id'][];
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
blockUser: {
|
||||
id: 'settings.block-user',
|
||||
defaultMessage: 'Do you want to block {userName}?',
|
||||
},
|
||||
unblockUser: {
|
||||
id: 'settings.unblock-user',
|
||||
defaultMessage: 'Do you want to unblock {userName}?',
|
||||
},
|
||||
hiddenUsers: {
|
||||
id: 'settings.hidden-users-title',
|
||||
defaultMessage: 'Hidden users',
|
||||
},
|
||||
blockedUsers: {
|
||||
id: 'settings.blocked-users-title',
|
||||
defaultMessage: 'Blocked users',
|
||||
},
|
||||
});
|
||||
|
||||
export default class Settings extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -42,7 +63,7 @@ export default class Settings extends Component<Props, State> {
|
||||
}
|
||||
|
||||
block = (user: BlockedUser) => {
|
||||
if (!confirm(`Do you want to block ${user.name}?`)) return;
|
||||
if (!confirm(this.props.intl.formatMessage(messages.blockUser, { userName: user.name }))) return;
|
||||
this.setState({
|
||||
unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id),
|
||||
});
|
||||
@@ -50,7 +71,7 @@ export default class Settings extends Component<Props, State> {
|
||||
};
|
||||
|
||||
unblock = (user: BlockedUser) => {
|
||||
if (!confirm(`Do you want to unblock ${user.name}?`)) return;
|
||||
if (!confirm(this.props.intl.formatMessage(messages.unblockUser, { userName: user.name }))) return;
|
||||
this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) });
|
||||
this.props.unblockUser(user.id);
|
||||
this.props.onUnblockSomeone();
|
||||
@@ -76,11 +97,22 @@ export default class Settings extends Component<Props, State> {
|
||||
|
||||
render({ user, theme }: Props, { blockedUsers, unblockedUsers, unhiddenUsers }: State) {
|
||||
const hiddenUsersList = Object.values(this.state.hiddenUsers);
|
||||
const intl = this.props.intl;
|
||||
return (
|
||||
<div className={b('settings', {}, { theme })}>
|
||||
<div className="settings__section settings__hidden-users" role="region" aria-label="Hidden users">
|
||||
<h3>Hidden users:</h3>
|
||||
{!hiddenUsersList.length && <h4 className="settings__dimmed">There are no hidden users.</h4>}
|
||||
<div
|
||||
className="settings__section settings__hidden-users"
|
||||
role="region"
|
||||
aria-label={intl.formatMessage(messages.hiddenUsers)}
|
||||
>
|
||||
<h3>
|
||||
<FormattedMessage id="settings.hidden-user-header" defaultMessage="Hidden users:" />
|
||||
</h3>
|
||||
{!hiddenUsersList.length && (
|
||||
<h4 className="settings__dimmed">
|
||||
<FormattedMessage id="settings.no-hidden-users" defaultMessage="There are no hidden users." />
|
||||
</h4>
|
||||
)}
|
||||
{!!hiddenUsersList.length && (
|
||||
<ul className="settings__list">
|
||||
{hiddenUsersList.map(user => {
|
||||
@@ -92,15 +124,15 @@ export default class Settings extends Component<Props, State> {
|
||||
className={['settings__username', isUserUnhidden ? 'settings__invisible' : null].join(' ')}
|
||||
title={user.id}
|
||||
>
|
||||
{user.name || 'unknown'}
|
||||
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
|
||||
</span>
|
||||
{this.__isUserHidden(user) ? (
|
||||
<span className="settings__action" {...getHandleClickProps(() => this.unhide(user))}>
|
||||
show
|
||||
<FormattedMessage id="settings.show" defaultMessage="show" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="settings__action" {...getHandleClickProps(() => this.hide(user))}>
|
||||
hide
|
||||
<FormattedMessage id="settings.hide" defaultMessage="hide" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
@@ -115,10 +147,20 @@ export default class Settings extends Component<Props, State> {
|
||||
)}
|
||||
</div>
|
||||
{user && user.admin && (
|
||||
<div className="settings__section settings__blocked-users" role="region" aria-label="Blocked users">
|
||||
<h3>Blocked users:</h3>
|
||||
<div
|
||||
className="settings__section settings__blocked-users"
|
||||
role="region"
|
||||
aria-label={intl.formatMessage(messages.blockedUsers)}
|
||||
>
|
||||
<h3>
|
||||
<FormattedMessage id="settings.blocked-users-header" defaultMessage="Blocked users:" />
|
||||
</h3>
|
||||
|
||||
{!blockedUsers.length && <h4 className="settings__dimmed">There are no blocked users.</h4>}
|
||||
{!blockedUsers.length && (
|
||||
<h4 className="settings__dimmed">
|
||||
<FormattedMessage id="settings.no-blocked-users" defaultMessage="There are no blocked users." />
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{!!blockedUsers.length && (
|
||||
<ul className="settings__list settings__blocked-users-list">
|
||||
@@ -131,17 +173,20 @@ export default class Settings extends Component<Props, State> {
|
||||
className={['settings__username', isUserUnblocked ? 'settings__invisible' : null].join(' ')}
|
||||
title={user.id}
|
||||
>
|
||||
{user.name || 'unknown'}
|
||||
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
|
||||
</span>
|
||||
<span className="settings__blocked-users-user-block-ttl">
|
||||
{' '}
|
||||
<FormatTime time={new Date(user.time)} />
|
||||
</span>
|
||||
<span className="settings__blocked-users-user-block-ttl"> {formatTime(new Date(user.time))}</span>
|
||||
{isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.block(user))} className="settings__action">
|
||||
block
|
||||
<FormattedMessage id="settings.block" defaultMessage="block" />
|
||||
</span>
|
||||
)}
|
||||
{!isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.unblock(user))} className="settings__action">
|
||||
unblock
|
||||
<FormattedMessage id="settings.unblock" defaultMessage="unblock" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
@@ -163,16 +208,20 @@ export default class Settings extends Component<Props, State> {
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
function formatTime(time: Date): string {
|
||||
function FormatTime({ time }: { time: Date }) {
|
||||
const intl = useIntl();
|
||||
// let's assume that if block ttl is more than 50 years then user blocked permanently
|
||||
if (time.getFullYear() - currentYear >= 50) return 'permanently';
|
||||
if (time.getFullYear() - currentYear >= 50)
|
||||
return <FormattedMessage id="settings.permanently" defaultMessage="permanently" />;
|
||||
|
||||
// 'ru-RU' adds a dot as a separator
|
||||
const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
|
||||
// do it manually because Intl API doesn't add leading zeros to hours; idk why
|
||||
const hours = `0${time.getHours()}`.slice(-2);
|
||||
const mins = `0${time.getMinutes()}`.slice(-2);
|
||||
|
||||
return `until ${date} at ${hours}:${mins}`;
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="settings.block-time"
|
||||
defaultMessage="until {day} at {time}"
|
||||
values={{
|
||||
day: intl.formatDate(time),
|
||||
time: intl.formatTime(time),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getThreadIsCollapsed } from '@app/store/thread/getters';
|
||||
import { InView } from '@app/components/root/in-view/in-view';
|
||||
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
|
||||
import { CommentForm } from '@app/components/comment-form';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
interface Props {
|
||||
id: CommentInterface['id'];
|
||||
@@ -33,6 +34,7 @@ const commentSelector = (id: string) => (state: StoreState) => {
|
||||
|
||||
export const Thread: FunctionComponent<Props> = ({ id, level, mix, getPreview }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const { collapsed, comment, childs, theme } = useSelector(commentSelector(id), shallowEqual);
|
||||
const collapse = useCallback(() => {
|
||||
dispatch(setCollapse(id, !collapsed));
|
||||
@@ -56,6 +58,7 @@ export const Thread: FunctionComponent<Props> = ({ id, level, mix, getPreview })
|
||||
ref={ref => inviewProps.ref(ref)}
|
||||
key={`comment-${id}`}
|
||||
view="main"
|
||||
intl={intl}
|
||||
data={comment}
|
||||
repliesCount={repliesCount}
|
||||
level={level}
|
||||
|
||||
@@ -10,7 +10,6 @@ const LastCommentsList = ({ comments, isLoading }: { comments: CommentType[]; is
|
||||
if (isLoading) {
|
||||
return <Preloader mix="user-info__preloader" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{comments.map(comment => (
|
||||
|
||||
@@ -13,12 +13,20 @@ import { AvatarIcon } from '../avatar-icon';
|
||||
import postMessage from '@app/utils/postMessage';
|
||||
import { bindActions } from '@app/utils/actionBinder';
|
||||
import { useActions } from '@app/hooks/useAction';
|
||||
import { useIntl, defineMessages, FormattedMessage, IntlShape } from 'react-intl';
|
||||
|
||||
const boundActions = bindActions({ fetchInfo });
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedError: {
|
||||
id: 'user-info.unexpected-error',
|
||||
defaultMessage: 'Something went wrong',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
comments: Comment[] | null;
|
||||
} & typeof boundActions;
|
||||
} & typeof boundActions & { intl: IntlShape };
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
@@ -36,7 +44,7 @@ class UserInfo extends Component<Props, State> {
|
||||
this.setState({ isLoading: false });
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ isLoading: false, error: 'Something went wrong' });
|
||||
this.setState({ isLoading: false, error: this.props.intl.formatMessage(messages.unexpectedError) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +68,13 @@ class UserInfo extends Component<Props, State> {
|
||||
return (
|
||||
<div className={b('user-info', {})}>
|
||||
<AvatarIcon mix="user-info__avatar" picture={user.picture} />
|
||||
<p className="user-info__title">Last comments by {user.name}</p>
|
||||
<p className="user-info__title">
|
||||
<FormattedMessage
|
||||
id="user-info.last-comments"
|
||||
defaultMessage="Last comments by {userName}"
|
||||
values={{ userName: user.name }}
|
||||
/>
|
||||
</p>
|
||||
<p className="user-info__id">{user.id}</p>
|
||||
|
||||
{!!comments && <LastCommentsList isLoading={isLoading} comments={comments} />}
|
||||
@@ -85,5 +99,6 @@ const commentsSelector = (state: StoreState) => state.userComments![userInfo.id!
|
||||
export const ConnectedUserInfo: FunctionComponent = () => {
|
||||
const comments = useSelector(commentsSelector);
|
||||
const actions = useActions(boundActions);
|
||||
return <UserInfo comments={comments} {...actions} />;
|
||||
const intl = useIntl();
|
||||
return <UserInfo comments={comments} {...actions} intl={intl} />;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,9 @@ import { getLastComments } from './common/api';
|
||||
import { LastCommentsConfig } from '@app/common/config-types';
|
||||
import { BASE_URL, DEFAULT_LAST_COMMENTS_MAX, LAST_COMMENTS_NODE_CLASSNAME } from '@app/common/constants';
|
||||
import { ListComments } from '@app/components/list-comments';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { loadLocale } from './utils/loadLocale';
|
||||
import { getLocale } from './utils/getLocale';
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -55,9 +58,15 @@ async function init(): Promise<void> {
|
||||
(node.dataset.max && parseInt(node.dataset.max, 10)) ||
|
||||
remark_config.max_last_comments ||
|
||||
DEFAULT_LAST_COMMENTS_MAX;
|
||||
getLastComments(remark_config.site_id!, max).then(comments => {
|
||||
const locale = getLocale(remark_config);
|
||||
Promise.all([getLastComments(remark_config.site_id!, max), loadLocale(locale)]).then(([comments, messages]) => {
|
||||
try {
|
||||
render(<ListComments comments={comments} />, node);
|
||||
render(
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ListComments comments={comments} />
|
||||
</IntlProvider>,
|
||||
node
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Remark42: Something went wrong with last comments rendering');
|
||||
console.error(e);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
|
||||
"anonymousLoginForm.log-in": "Log in",
|
||||
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
|
||||
"anonymousLoginForm.user-name": "Username",
|
||||
"authPanel.anonymous-provider": "Anonymous",
|
||||
"authPanel.disable-comments": "Disable comments",
|
||||
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
|
||||
"authPanel.enable-comments": "Enable comments",
|
||||
"authPanel.enable-cookies": "Allow cookies to login and comment",
|
||||
"authPanel.hide-settings": "Hide settings",
|
||||
"authPanel.logged-as": "You logged in as",
|
||||
"authPanel.login": "Login:",
|
||||
"authPanel.logout": "Logout?",
|
||||
"authPanel.or-provider": "or",
|
||||
"authPanel.other-provider": "Other",
|
||||
"authPanel.read-only": "Read-only",
|
||||
"authPanel.request-to-delete-data": "Request my data removal",
|
||||
"authPanel.show-settings": "Show settings",
|
||||
"blockingDuration.day": "For a day",
|
||||
"blockingDuration.month": "For a month",
|
||||
"blockingDuration.permanently": "Permanently",
|
||||
"blockingDuration.week": "For a week",
|
||||
"comment.block": "Block",
|
||||
"comment.block-user": "Do you want to block {userName} {duration}?",
|
||||
"comment.blocked-user": "Blocked",
|
||||
"comment.blocking-period": "Blocking period",
|
||||
"comment.cancel": "Cancel",
|
||||
"comment.controversy": "Controversy: {value}",
|
||||
"comment.copied": "Copied!",
|
||||
"comment.copy": "Copy",
|
||||
"comment.delete": "Delete",
|
||||
"comment.delete-message": "Do you want to delete this comment?",
|
||||
"comment.deleted-comment": "This comment was deleted",
|
||||
"comment.deleted-user": "Deleted",
|
||||
"comment.edit": "Edit",
|
||||
"comment.expired-time": "Editing time has expired.",
|
||||
"comment.go-to-parent": "Go to parent comment",
|
||||
"comment.hide": "Hide",
|
||||
"comment.hide-user-comment": "Do you want to hide comments of {userName}?",
|
||||
"comment.pin": "Pin",
|
||||
"comment.pin-comment": "Do you want to pin this comment?",
|
||||
"comment.reply": "Reply",
|
||||
"comment.time": "{day} at {time}",
|
||||
"comment.toggle-verification": "Toggle verification",
|
||||
"comment.unblock": "Unblock",
|
||||
"comment.unblock-user": "Do you want to unblock this user?",
|
||||
"comment.unpin": "Unpin",
|
||||
"comment.unpin-comment": "Do you want to unpin this comment?",
|
||||
"comment.unverified-user": "Unverified user",
|
||||
"comment.unverify-user": "Do you want to unverify {userName}?",
|
||||
"comment.verified-user": "Verified user",
|
||||
"comment.verify-user": "Do you want to verify {userName}?",
|
||||
"comment.vote-error": "Voting error: {voteErrorMessage}",
|
||||
"commentForm.exceeded-size": "{fileName} exceeds size limit of {maxImageSize}",
|
||||
"commentForm.input-placeholder": "Your comment here",
|
||||
"commentForm.new-comment": "New comment",
|
||||
"commentForm.notice-about-styling": "Styling with <a>Markdown</a> is supported",
|
||||
"commentForm.preview": "Preview",
|
||||
"commentForm.reply": "Reply",
|
||||
"commentForm.save": "Save",
|
||||
"commentForm.send": "Send",
|
||||
"commentForm.subscribe-by": "Subscribe by",
|
||||
"commentForm.subscribe-or": "or",
|
||||
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
|
||||
"commentForm.upload-file-fail": "{fileName} upload failed with \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Uploading...",
|
||||
"commentForm.uploading-file": "uploading {fileName}...",
|
||||
"commentSort.sort-by": "Sort by",
|
||||
"commentsSort.best": "Best",
|
||||
"commentsSort.least-controversial": "Least controversial",
|
||||
"commentsSort.least-recently-updated": "Least recently updated",
|
||||
"commentsSort.most-controversial": "Most controversial",
|
||||
"commentsSort.newest": "Newest",
|
||||
"commentsSort.oldest": "Oldest",
|
||||
"commentsSort.recently-updated": "Recently updated",
|
||||
"commentsSort.worst": "Worst",
|
||||
"emailLoginForm.back": "Back",
|
||||
"emailLoginForm.confirm": "Confirm",
|
||||
"emailLoginForm.email-address": "Email Address",
|
||||
"emailLoginForm.empty-token": "Token field must not be empty",
|
||||
"emailLoginForm.expired-token": "Token is expired",
|
||||
"emailLoginForm.invalid-email": "Address should be valid email address",
|
||||
"emailLoginForm.loading": "Loading...",
|
||||
"emailLoginForm.send-verification": "Send Verification",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "No user was found",
|
||||
"errors.0": "Something went wrong. Please try again a bit later.",
|
||||
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.10": "It is too late to edit the comment.",
|
||||
"errors.11": "Comment already has reply, editing is not possible.",
|
||||
"errors.12": "Cannot save voting result. Please try again a bit later.",
|
||||
"errors.13": "You cannot vote for your own comment.",
|
||||
"errors.14": "You have already voted for the comment.",
|
||||
"errors.15": "Too many votes for the comment.",
|
||||
"errors.16": "Min score reached for the comment.",
|
||||
"errors.17": "Action rejected. Please try again a bit later.",
|
||||
"errors.18": "Requested file cannot be found.",
|
||||
"errors.2": "Failed to unmarshal incoming request.",
|
||||
"errors.3": "You don't have permission for this operation.",
|
||||
"errors.4": "Invalid comment data.",
|
||||
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.6": "Site cannot be found. Please refresh the page and try again.",
|
||||
"errors.7": "User has been blocked.",
|
||||
"errors.8": "User has been blocked.",
|
||||
"errors.9": "Comment changing failed. Please try again a bit later.",
|
||||
"errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later",
|
||||
"errors.forbidden": "Forbidden.",
|
||||
"errors.not-authorized": "Not authorized.",
|
||||
"errors.to-many-request": "You have reached maximum request limit.",
|
||||
"errors.unexpected-error": "Something went wrong.",
|
||||
"root.pinned-comments": "Pinned comments",
|
||||
"root.powered-by": "Powered by <a>Remark42</a>",
|
||||
"root.show-more": "Show more",
|
||||
"settings.block": "block",
|
||||
"settings.block-time": "until {day} at {time}",
|
||||
"settings.block-user": "Do you want to block {userName}?",
|
||||
"settings.blocked-users-header": "Blocked users:",
|
||||
"settings.blocked-users-title": "Blocked users",
|
||||
"settings.hidden-user-header": "Hidden users:",
|
||||
"settings.hidden-users-title": "Hidden users",
|
||||
"settings.hide": "hide",
|
||||
"settings.no-blocked-users": "There are no blocked users.",
|
||||
"settings.no-hidden-users": "There are no hidden users.",
|
||||
"settings.permanently": "permanently",
|
||||
"settings.show": "show",
|
||||
"settings.unblock": "unblock",
|
||||
"settings.unblock-user": "Do you want to unblock {userName}?",
|
||||
"settings.unknown": "unknown",
|
||||
"subscribeByEmail.back": "Back",
|
||||
"subscribeByEmail.close": "Close",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Expired token",
|
||||
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
|
||||
"subscribeByEmail.only-registered-users": "Available only for registered users",
|
||||
"subscribeByEmail.submit": "Submit",
|
||||
"subscribeByEmail.subscribe": "Subscribe",
|
||||
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
|
||||
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Unsubscribe",
|
||||
"subscribeByRSS.button-title": "Subscribe by RSS",
|
||||
"subscribeByRSS.replies": "Replies",
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Thread",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
|
||||
"toolbar.bold": "Add bold text <cmd-b>",
|
||||
"toolbar.code": "Insert a code",
|
||||
"toolbar.header": "Add header text",
|
||||
"toolbar.italic": "Add italic text <cmd-i>",
|
||||
"toolbar.link": "Add a link <cmd-k>",
|
||||
"toolbar.ordered-list": "Add a numbered list",
|
||||
"toolbar.quote": "Insert a quote",
|
||||
"toolbar.unordered-list": "Add a bulleted list",
|
||||
"user-info.last-comments": "Last comments by {userName}",
|
||||
"user-info.unexpected-error": "Something went wrong",
|
||||
"vote.anonymous": "Anonymous users can't vote",
|
||||
"vote.deleted": "Can't vote for deleted comment",
|
||||
"vote.guest": "Sign in to vote",
|
||||
"vote.only-positive": "Only positive score allowed",
|
||||
"vote.only-post-page": "Voting allowed only on post's page",
|
||||
"vote.own-comment": "Can't vote for your own comment",
|
||||
"vote.readonly": "Can't vote on read-only topics"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
|
||||
"anonymousLoginForm.log-in": "Log in",
|
||||
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
|
||||
"anonymousLoginForm.user-name": "Username",
|
||||
"authPanel.anonymous-provider": "Anonymous",
|
||||
"authPanel.disable-comments": "Disable comments",
|
||||
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
|
||||
"authPanel.enable-comments": "Enable comments",
|
||||
"authPanel.enable-cookies": "Allow cookies to login and comment",
|
||||
"authPanel.hide-settings": "Hide settings",
|
||||
"authPanel.logged-as": "You logged in as",
|
||||
"authPanel.login": "Login:",
|
||||
"authPanel.logout": "Logout?",
|
||||
"authPanel.or-provider": "or",
|
||||
"authPanel.other-provider": "Other",
|
||||
"authPanel.read-only": "Read-only",
|
||||
"authPanel.request-to-delete-data": "Request my data removal",
|
||||
"authPanel.show-settings": "Show settings",
|
||||
"blockingDuration.day": "For a day",
|
||||
"blockingDuration.month": "For a month",
|
||||
"blockingDuration.permanently": "Permanently",
|
||||
"blockingDuration.week": "For a week",
|
||||
"comment.block": "Block",
|
||||
"comment.block-user": "Do you want to block {userName} {duration}?",
|
||||
"comment.blocked-user": "Blocked",
|
||||
"comment.blocking-period": "Blocking period",
|
||||
"comment.cancel": "Cancel",
|
||||
"comment.controversy": "Controversy: {value}",
|
||||
"comment.copied": "Copied!",
|
||||
"comment.copy": "Copy",
|
||||
"comment.delete": "Delete",
|
||||
"comment.delete-message": "Do you want to delete this comment?",
|
||||
"comment.deleted-comment": "This comment was deleted",
|
||||
"comment.deleted-user": "Deleted",
|
||||
"comment.edit": "Edit",
|
||||
"comment.expired-time": "Editing time has expired.",
|
||||
"comment.go-to-parent": "Go to parent comment",
|
||||
"comment.hide": "Hide",
|
||||
"comment.hide-user-comment": "Do you want to hide comments of {userName}?",
|
||||
"comment.pin": "Pin",
|
||||
"comment.pin-comment": "Do you want to pin this comment?",
|
||||
"comment.reply": "Reply",
|
||||
"comment.time": "{day} at {time}",
|
||||
"comment.toggle-verification": "Toggle verification",
|
||||
"comment.unblock": "Unblock",
|
||||
"comment.unblock-user": "Do you want to unblock this user?",
|
||||
"comment.unpin": "Unpin",
|
||||
"comment.unpin-comment": "Do you want to unpin this comment?",
|
||||
"comment.unverified-user": "Unverified user",
|
||||
"comment.unverify-user": "Do you want to unverify {userName}?",
|
||||
"comment.verified-user": "Verified user",
|
||||
"comment.verify-user": "Do you want to verify {userName}?",
|
||||
"comment.vote-error": "Voting error: {voteErrorMessage}",
|
||||
"commentForm.exceeded-size": "{fileName} exceeds size limit of {maxImageSize}",
|
||||
"commentForm.input-placeholder": "Your comment here",
|
||||
"commentForm.new-comment": "New comment",
|
||||
"commentForm.notice-about-styling": "Styling with <a>Markdown</a> is supported",
|
||||
"commentForm.preview": "Preview",
|
||||
"commentForm.reply": "Reply",
|
||||
"commentForm.save": "Save",
|
||||
"commentForm.send": "Send",
|
||||
"commentForm.subscribe-by": "Subscribe by",
|
||||
"commentForm.subscribe-or": "or",
|
||||
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
|
||||
"commentForm.upload-file-fail": "{fileName} upload failed with \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Uploading...",
|
||||
"commentForm.uploading-file": "uploading {fileName}...",
|
||||
"commentSort.sort-by": "Sort by",
|
||||
"commentsSort.best": "Best",
|
||||
"commentsSort.least-controversial": "Least controversial",
|
||||
"commentsSort.least-recently-updated": "Least recently updated",
|
||||
"commentsSort.most-controversial": "Most controversial",
|
||||
"commentsSort.newest": "Newest",
|
||||
"commentsSort.oldest": "Oldest",
|
||||
"commentsSort.recently-updated": "Recently updated",
|
||||
"commentsSort.worst": "Worst",
|
||||
"emailLoginForm.back": "Back",
|
||||
"emailLoginForm.confirm": "Confirm",
|
||||
"emailLoginForm.email-address": "Email Address",
|
||||
"emailLoginForm.empty-token": "Token field must not be empty",
|
||||
"emailLoginForm.expired-token": "Token is expired",
|
||||
"emailLoginForm.invalid-email": "Address should be valid email address",
|
||||
"emailLoginForm.loading": "Loading...",
|
||||
"emailLoginForm.send-verification": "Send Verification",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "No user was found",
|
||||
"errors.0": "Something went wrong. Please try again a bit later.",
|
||||
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.10": "It is too late to edit the comment.",
|
||||
"errors.11": "Comment already has reply, editing is not possible.",
|
||||
"errors.12": "Cannot save voting result. Please try again a bit later.",
|
||||
"errors.13": "You cannot vote for your own comment.",
|
||||
"errors.14": "You have already voted for the comment.",
|
||||
"errors.15": "Too many votes for the comment.",
|
||||
"errors.16": "Min score reached for the comment.",
|
||||
"errors.17": "Action rejected. Please try again a bit later.",
|
||||
"errors.18": "Requested file cannot be found.",
|
||||
"errors.2": "Failed to unmarshal incoming request.",
|
||||
"errors.3": "You don't have permission for this operation.",
|
||||
"errors.4": "Invalid comment data.",
|
||||
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.6": "Site cannot be found. Please refresh the page and try again.",
|
||||
"errors.7": "User has been blocked.",
|
||||
"errors.8": "User has been blocked.",
|
||||
"errors.9": "Comment changing failed. Please try again a bit later.",
|
||||
"errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later",
|
||||
"errors.forbidden": "Forbidden.",
|
||||
"errors.not-authorized": "Not authorized.",
|
||||
"errors.to-many-request": "You have reached maximum request limit.",
|
||||
"errors.unexpected-error": "Something went wrong.",
|
||||
"root.pinned-comments": "Pinned comments",
|
||||
"root.powered-by": "Powered by <a>Remark42</a>",
|
||||
"root.show-more": "Show more",
|
||||
"settings.block": "block",
|
||||
"settings.block-time": "until {day} at {time}",
|
||||
"settings.block-user": "Do you want to block {userName}?",
|
||||
"settings.blocked-users-header": "Blocked users:",
|
||||
"settings.blocked-users-title": "Blocked users",
|
||||
"settings.hidden-user-header": "Hidden users:",
|
||||
"settings.hidden-users-title": "Hidden users",
|
||||
"settings.hide": "hide",
|
||||
"settings.no-blocked-users": "There are no blocked users.",
|
||||
"settings.no-hidden-users": "There are no hidden users.",
|
||||
"settings.permanently": "permanently",
|
||||
"settings.show": "show",
|
||||
"settings.unblock": "unblock",
|
||||
"settings.unblock-user": "Do you want to unblock {userName}?",
|
||||
"settings.unknown": "unknown",
|
||||
"subscribeByEmail.back": "Back",
|
||||
"subscribeByEmail.close": "Close",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Expired token",
|
||||
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
|
||||
"subscribeByEmail.only-registered-users": "Available only for registered users",
|
||||
"subscribeByEmail.submit": "Submit",
|
||||
"subscribeByEmail.subscribe": "Subscribe",
|
||||
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
|
||||
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Unsubscribe",
|
||||
"subscribeByRSS.button-title": "Subscribe by RSS",
|
||||
"subscribeByRSS.replies": "Replies",
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Thread",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
|
||||
"toolbar.bold": "Add bold text <cmd-b>",
|
||||
"toolbar.code": "Insert a code",
|
||||
"toolbar.header": "Add header text",
|
||||
"toolbar.italic": "Add italic text <cmd-i>",
|
||||
"toolbar.link": "Add a link <cmd-k>",
|
||||
"toolbar.ordered-list": "Add a numbered list",
|
||||
"toolbar.quote": "Insert a quote",
|
||||
"toolbar.unordered-list": "Add a bulleted list",
|
||||
"user-info.last-comments": "Last comments by {userName}",
|
||||
"user-info.unexpected-error": "Something went wrong",
|
||||
"vote.anonymous": "Anonymous users can't vote",
|
||||
"vote.deleted": "Can't vote for deleted comment",
|
||||
"vote.guest": "Sign in to vote",
|
||||
"vote.only-positive": "Only positive score allowed",
|
||||
"vote.only-post-page": "Voting allowed only on post's page",
|
||||
"vote.own-comment": "Can't vote for your own comment",
|
||||
"vote.readonly": "Can't vote on read-only topics"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Длина имени должна быть больше 3 символов",
|
||||
"anonymousLoginForm.log-in": "Войти",
|
||||
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
|
||||
"anonymousLoginForm.user-name": "Username",
|
||||
"authPanel.anonymous-provider": "Анонимно",
|
||||
"authPanel.disable-comments": "Выключить комментарии",
|
||||
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
|
||||
"authPanel.enable-comments": "Включить комментарии",
|
||||
"authPanel.enable-cookies": "Allow cookies to login and comment",
|
||||
"authPanel.hide-settings": "Спрятать настройки",
|
||||
"authPanel.logged-as": "Вы вошли как",
|
||||
"authPanel.login": "Вход:",
|
||||
"authPanel.logout": "Выйти?",
|
||||
"authPanel.or-provider": "или",
|
||||
"authPanel.other-provider": "Другой",
|
||||
"authPanel.read-only": "Только для чтение",
|
||||
"authPanel.request-to-delete-data": "Запросить удаление моих данных",
|
||||
"authPanel.show-settings": "Показать настройки",
|
||||
"blockingDuration.day": "На день",
|
||||
"blockingDuration.month": "На месяц",
|
||||
"blockingDuration.permanently": "Навсегда",
|
||||
"blockingDuration.week": "На неделю",
|
||||
"comment.block": "Блокировать",
|
||||
"comment.block-user": "Заблокировать пользователя {userName} {duration}?",
|
||||
"comment.blocked-user": "Заблокирован",
|
||||
"comment.blocking-period": "Период блокировки",
|
||||
"comment.cancel": "Отменить",
|
||||
"comment.controversy": "Спорность: {value}",
|
||||
"comment.copied": "Скопировано!",
|
||||
"comment.copy": "Копировать",
|
||||
"comment.delete": "Удалить",
|
||||
"comment.delete-message": "Удалить комментарий?",
|
||||
"comment.deleted-comment": "Комментарий был удалён",
|
||||
"comment.deleted-user": "Удалён",
|
||||
"comment.edit": "Редактировать",
|
||||
"comment.expired-time": "Время редактирования истекло.",
|
||||
"comment.go-to-parent": "Go to parent comment",
|
||||
"comment.hide": "Спрятать",
|
||||
"comment.hide-user-comment": "Спрятать комментарии от пользователя {userName}?",
|
||||
"comment.pin": "Закрепить",
|
||||
"comment.pin-comment": "Закрепить комментарий?",
|
||||
"comment.reply": "Ответить",
|
||||
"comment.time": "{day} в {time}",
|
||||
"comment.toggle-verification": "Изменить статут подлинности учетной записи",
|
||||
"comment.unblock": "Разблокировать",
|
||||
"comment.unblock-user": "Разбокировать пользователя?",
|
||||
"comment.unpin": "Открепить",
|
||||
"comment.unpin-comment": "Открепить комментарий?",
|
||||
"comment.unverified-user": " Не проверенная на подлинность учетная запись",
|
||||
"comment.unverify-user": "Убрать статус подлинности учетной записи для {userName}?",
|
||||
"comment.verified-user": "Подлинная учетная запись",
|
||||
"comment.verify-user": "Подвердить подлинность учетной записи для {userName}?",
|
||||
"comment.vote-error": "Ошибка голосования: {voteErrorMessage}",
|
||||
"commentForm.exceeded-size": "Размер файла {fileName} должен быть меньше чем {maxImageSize}",
|
||||
"commentForm.input-placeholder": "Написать комментарий",
|
||||
"commentForm.new-comment": "New comment",
|
||||
"commentForm.notice-about-styling": "Поддерживается <a>Markdown</a> форматирование",
|
||||
"commentForm.preview": "Предпросмотр",
|
||||
"commentForm.reply": "Ответить",
|
||||
"commentForm.save": "Сохранить",
|
||||
"commentForm.send": "Отправить",
|
||||
"commentForm.subscribe-by": "Подписаться",
|
||||
"commentForm.subscribe-or": "или",
|
||||
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
|
||||
"commentForm.upload-file-fail": "{fileName} невозможно загрузить из-за ошибки: \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Загрузка...",
|
||||
"commentForm.uploading-file": "Загрузка {fileName}...",
|
||||
"commentSort.sort-by": "Сортировать по",
|
||||
"commentsSort.best": "Лучшие",
|
||||
"commentsSort.least-controversial": "Наименее спорные",
|
||||
"commentsSort.least-recently-updated": "Давно обновленные",
|
||||
"commentsSort.most-controversial": "Наиболее спорные",
|
||||
"commentsSort.newest": "Новые",
|
||||
"commentsSort.oldest": "Старые",
|
||||
"commentsSort.recently-updated": "Недавно обновленные",
|
||||
"commentsSort.worst": "Худшие",
|
||||
"emailLoginForm.back": "Back",
|
||||
"emailLoginForm.confirm": "Confirm",
|
||||
"emailLoginForm.email-address": "Email Address",
|
||||
"emailLoginForm.empty-token": "Token field must not be empty",
|
||||
"emailLoginForm.expired-token": "Token is expired",
|
||||
"emailLoginForm.invalid-email": "Address should be valid email address",
|
||||
"emailLoginForm.loading": "Loading...",
|
||||
"emailLoginForm.send-verification": "Send Verification",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "No user was found",
|
||||
"errors.0": "Something went wrong. Please try again a bit later.",
|
||||
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.10": "It is too late to edit the comment.",
|
||||
"errors.11": "Comment already has reply, editing is not possible.",
|
||||
"errors.12": "Cannot save voting result. Please try again a bit later.",
|
||||
"errors.13": "You cannot vote for your own comment.",
|
||||
"errors.14": "Вы уже голосовали за этот комментарий.",
|
||||
"errors.15": "Too many votes for the comment.",
|
||||
"errors.16": "Min score reached for the comment.",
|
||||
"errors.17": "Action rejected. Please try again a bit later.",
|
||||
"errors.18": "Requested file cannot be found.",
|
||||
"errors.2": "Failed to unmarshal incoming request.",
|
||||
"errors.3": "You don't have permission for this operation.",
|
||||
"errors.4": "Invalid comment data.",
|
||||
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
|
||||
"errors.6": "Site cannot be found. Please refresh the page and try again.",
|
||||
"errors.7": "User has been blocked.",
|
||||
"errors.8": "User has been blocked.",
|
||||
"errors.9": "Comment changing failed. Please try again a bit later.",
|
||||
"errors.failed-fetch": "Нет ответа с сервера. Проверьте ваше соеденение с интернетом или попробуйте позже.",
|
||||
"errors.forbidden": "Forbidden.",
|
||||
"errors.not-authorized": "Not authorized.",
|
||||
"errors.to-many-request": "Слишком много запросов.",
|
||||
"errors.unexpected-error": "Что-то пошло не так.",
|
||||
"root.pinned-comments": "Закреплённые комментарии",
|
||||
"root.powered-by": "Powered by <a>Remark42</a>",
|
||||
"root.show-more": "Показать ещё",
|
||||
"settings.block": "блокировать",
|
||||
"settings.block-time": "до {day} в {time}",
|
||||
"settings.block-user": "Заблокировать пользователя {userName}?",
|
||||
"settings.blocked-users-header": "Заблокированные пользователи:",
|
||||
"settings.blocked-users-title": "Заблокированные пользователи",
|
||||
"settings.hidden-user-header": "Скрытые пользователи:",
|
||||
"settings.hidden-users-title": "Скрытые пользователи",
|
||||
"settings.hide": "Спрятать",
|
||||
"settings.no-blocked-users": "Нет заблокированных пользователей.",
|
||||
"settings.no-hidden-users": "Нет скрытых пользователей.",
|
||||
"settings.permanently": "навсегда",
|
||||
"settings.show": "Показать",
|
||||
"settings.unblock": "разблокировать",
|
||||
"settings.unblock-user": "Разблокировать пользователя {userName}?",
|
||||
"settings.unknown": "безымянный",
|
||||
"subscribeByEmail.back": "Back",
|
||||
"subscribeByEmail.close": "Close",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Expired token",
|
||||
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
|
||||
"subscribeByEmail.only-registered-users": "Available only for registered users",
|
||||
"subscribeByEmail.submit": "Submit",
|
||||
"subscribeByEmail.subscribe": "Subscribe",
|
||||
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
|
||||
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Unsubscribe",
|
||||
"subscribeByRSS.button-title": "Subscribe by RSS",
|
||||
"subscribeByRSS.replies": "Replies",
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Thread",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
|
||||
"toolbar.bold": "Жирный <cmd-b>",
|
||||
"toolbar.code": "Код",
|
||||
"toolbar.header": "Заголовок",
|
||||
"toolbar.italic": "Курсив <cmd-i>",
|
||||
"toolbar.link": "Ссылка <cmd-k>",
|
||||
"toolbar.ordered-list": "Упорядоченный список",
|
||||
"toolbar.quote": "Цитата",
|
||||
"toolbar.unordered-list": "Неупорядоченный список",
|
||||
"user-info.last-comments": "Последние комментарии {userName}",
|
||||
"user-info.unexpected-error": "Something went wrong",
|
||||
"vote.anonymous": "Нельзя головать анонимному пользователю",
|
||||
"vote.deleted": "Нельзя головать за удалённый комментарий",
|
||||
"vote.guest": "Войдите в систему для голосование",
|
||||
"vote.only-positive": "Разрешены только положительные оценки",
|
||||
"vote.only-post-page": "Голосование возожно только для статей",
|
||||
"vote.own-comment": "Вы не можете голосовать за свои комментарии",
|
||||
"vote.readonly": "Нельзя головать за комментарии, которые в режиме только для чтения"
|
||||
}
|
||||
+19
-10
@@ -7,6 +7,10 @@ if (process.env.NODE_ENV === 'development') {
|
||||
}
|
||||
import loadPolyfills from '@app/common/polyfills';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { loadLocale } from './utils/loadLocale';
|
||||
import { getLocale } from './utils/getLocale';
|
||||
|
||||
import { createElement, render } from 'preact';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -61,26 +65,31 @@ async function init(): Promise<void> {
|
||||
}
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
const locale = getLocale(params);
|
||||
const messages = await loadLocale(locale).catch(() => ({}));
|
||||
StaticStore.config = await api.getConfig();
|
||||
|
||||
if (params.page === 'user-info') {
|
||||
return render(
|
||||
<div id={NODE_ID}>
|
||||
<div className="root root_user-info">
|
||||
<Provider store={reduxStore}>
|
||||
<UserInfo />
|
||||
</Provider>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<div id={NODE_ID}>
|
||||
<div className="root root_user-info">
|
||||
<Provider store={reduxStore}>
|
||||
<UserInfo />
|
||||
</Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
</IntlProvider>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider store={reduxStore}>
|
||||
<ConnectedRoot />
|
||||
</Provider>,
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={reduxStore}>
|
||||
<ConnectedRoot />
|
||||
</Provider>
|
||||
</IntlProvider>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,190 @@
|
||||
import { IntlShape, defineMessages, MessageDescriptor } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
failedFetch: {
|
||||
id: 'errors.failed-fetch',
|
||||
defaultMessage: 'Failed to fetch. Please check your internet connection or try again a bit later',
|
||||
description: {
|
||||
code: -2,
|
||||
},
|
||||
},
|
||||
0: {
|
||||
id: 'errors.0',
|
||||
defaultMessage: 'Something went wrong. Please try again a bit later.',
|
||||
description: {
|
||||
code: 0,
|
||||
},
|
||||
},
|
||||
1: {
|
||||
id: 'errors.1',
|
||||
defaultMessage: 'Comment cannot be found. Please refresh the page and try again.',
|
||||
description: {
|
||||
code: 1,
|
||||
},
|
||||
},
|
||||
2: {
|
||||
id: 'errors.2',
|
||||
defaultMessage: 'Failed to unmarshal incoming request.',
|
||||
description: {
|
||||
code: 2,
|
||||
},
|
||||
},
|
||||
3: {
|
||||
id: 'errors.3',
|
||||
defaultMessage: `You don't have permission for this operation.`,
|
||||
description: {
|
||||
code: 3,
|
||||
},
|
||||
},
|
||||
4: {
|
||||
id: 'errors.4',
|
||||
defaultMessage: `Invalid comment data.`,
|
||||
description: {
|
||||
code: 4,
|
||||
},
|
||||
},
|
||||
5: {
|
||||
id: 'errors.5',
|
||||
defaultMessage: `Comment cannot be found. Please refresh the page and try again.`,
|
||||
description: {
|
||||
code: 5,
|
||||
},
|
||||
},
|
||||
6: {
|
||||
id: 'errors.6',
|
||||
defaultMessage: `Site cannot be found. Please refresh the page and try again.`,
|
||||
description: {
|
||||
code: 6,
|
||||
},
|
||||
},
|
||||
7: {
|
||||
id: 'errors.7',
|
||||
defaultMessage: `User has been blocked.`,
|
||||
description: {
|
||||
code: 7,
|
||||
},
|
||||
},
|
||||
8: {
|
||||
id: 'errors.8',
|
||||
defaultMessage: `User has been blocked.`,
|
||||
description: {
|
||||
code: 8,
|
||||
},
|
||||
},
|
||||
9: {
|
||||
id: 'errors.9',
|
||||
defaultMessage: `Comment changing failed. Please try again a bit later.`,
|
||||
description: {
|
||||
code: 9,
|
||||
},
|
||||
},
|
||||
10: {
|
||||
id: 'errors.10',
|
||||
defaultMessage: `It is too late to edit the comment.`,
|
||||
description: {
|
||||
code: 10,
|
||||
},
|
||||
},
|
||||
11: {
|
||||
id: 'errors.11',
|
||||
defaultMessage: `Comment already has reply, editing is not possible.`,
|
||||
description: {
|
||||
code: 11,
|
||||
},
|
||||
},
|
||||
12: {
|
||||
id: 'errors.12',
|
||||
defaultMessage: `Cannot save voting result. Please try again a bit later.`,
|
||||
description: {
|
||||
code: 12,
|
||||
},
|
||||
},
|
||||
13: {
|
||||
id: 'errors.13',
|
||||
defaultMessage: `You cannot vote for your own comment.`,
|
||||
description: {
|
||||
code: 13,
|
||||
},
|
||||
},
|
||||
14: {
|
||||
id: 'errors.14',
|
||||
defaultMessage: `You have already voted for the comment.`,
|
||||
description: {
|
||||
code: 14,
|
||||
},
|
||||
},
|
||||
15: {
|
||||
id: 'errors.15',
|
||||
defaultMessage: `Too many votes for the comment.`,
|
||||
description: {
|
||||
code: 15,
|
||||
},
|
||||
},
|
||||
16: {
|
||||
id: 'errors.16',
|
||||
defaultMessage: `Min score reached for the comment.`,
|
||||
description: {
|
||||
code: 16,
|
||||
},
|
||||
},
|
||||
17: {
|
||||
id: 'errors.17',
|
||||
defaultMessage: `Action rejected. Please try again a bit later.`,
|
||||
description: {
|
||||
code: 17,
|
||||
},
|
||||
},
|
||||
18: {
|
||||
id: 'errors.18',
|
||||
defaultMessage: `Requested file cannot be found.`,
|
||||
description: {
|
||||
code: 18,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* map of codes that server returns in its response in case of error
|
||||
* to client readable version
|
||||
*/
|
||||
const errorMessageForCodes = new Map([
|
||||
[0, 'Something went wrong. Please try again a bit later.'],
|
||||
[1, 'Comment cannot be found. Please refresh the page and try again.'],
|
||||
[2, 'Failed to unmarshal incoming request.'],
|
||||
[3, "You don't have permission for this operaton."],
|
||||
[4, 'Invalid comment data.'],
|
||||
[5, 'Comment cannot be found. Please refresh the page and try again.'],
|
||||
[6, 'Site cannot be found. Please refresh the page and try again.'],
|
||||
[7, 'User has been blocked.'],
|
||||
[8, 'This post is read only.'],
|
||||
[9, 'Comment changing failed. Please try again a bit later.'],
|
||||
[10, 'It is too late to edit the comment.'],
|
||||
[11, 'Comment already has reply, editing is not possible.'],
|
||||
[12, 'Cannot save voting result. Please try again a bit later.'],
|
||||
[13, 'You cannot vote for your own comment.'],
|
||||
[14, 'You have already voted for the comment.'],
|
||||
[15, 'Too many votes for the comment.'],
|
||||
[16, 'Min score reached for the comment.'],
|
||||
[17, 'Action rejected. Please try again a bit later.'],
|
||||
[18, 'Requested file cannot be found.'],
|
||||
]);
|
||||
const errorMessageForCodes = new Map<number, MessageDescriptor>();
|
||||
|
||||
Object.entries(messages).forEach(([, messageDescriptor]) => {
|
||||
errorMessageForCodes.set(messageDescriptor.description.code, messageDescriptor);
|
||||
});
|
||||
|
||||
export const httpMessages = defineMessages({
|
||||
notAuthorized: {
|
||||
id: 'errors.not-authorized',
|
||||
defaultMessage: 'Not authorized.',
|
||||
},
|
||||
forbidden: {
|
||||
id: 'errors.forbidden',
|
||||
defaultMessage: 'Forbidden.',
|
||||
},
|
||||
toManyRequest: {
|
||||
id: 'errors.to-many-request',
|
||||
defaultMessage: 'You have reached maximum request limit.',
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'errors.unexpected-error',
|
||||
defaultMessage: 'Something went wrong.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* map of http rest codes to ui label, used by fetcher to generate error with `-1` code
|
||||
*/
|
||||
export const httpErrorMap = new Map([
|
||||
[401, 'Not authorized.'],
|
||||
[403, 'Forbidden.'],
|
||||
[429, 'You have reached maximum request limit.'],
|
||||
[401, httpMessages.notAuthorized],
|
||||
[403, httpMessages.forbidden],
|
||||
[429, httpMessages.toManyRequest],
|
||||
]);
|
||||
|
||||
export function isFailedFetch(e?: Error): boolean {
|
||||
return Boolean(e && e.message && e.message === `Failed to fetch`);
|
||||
}
|
||||
|
||||
export type FetcherError =
|
||||
| string
|
||||
| {
|
||||
@@ -46,9 +198,8 @@ export type FetcherError =
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function extractErrorMessageFromResponse(response: FetcherError): string {
|
||||
const defaultErrorMessage = errorMessageForCodes.get(0) as string;
|
||||
|
||||
export function extractErrorMessageFromResponse(response: FetcherError, intl: IntlShape): string {
|
||||
const defaultErrorMessage = intl.formatMessage(errorMessageForCodes.get(0) || messages['0']);
|
||||
if (!response) {
|
||||
return defaultErrorMessage;
|
||||
}
|
||||
@@ -57,16 +208,13 @@ export function extractErrorMessageFromResponse(response: FetcherError): string
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.code === -1) {
|
||||
return response.error;
|
||||
}
|
||||
|
||||
if (typeof response.details === 'string') {
|
||||
return response.details.charAt(0).toUpperCase() + response.details.substring(1);
|
||||
}
|
||||
|
||||
if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) {
|
||||
return errorMessageForCodes.get(response.code)!;
|
||||
if (
|
||||
typeof response.code === 'number' &&
|
||||
(errorMessageForCodes.has(response.code) || httpErrorMap.has(response.code))
|
||||
) {
|
||||
const messageDescriptor =
|
||||
errorMessageForCodes.get(response.code) || httpErrorMap.get(response.code) || messages['0'];
|
||||
return intl.formatMessage(messageDescriptor);
|
||||
}
|
||||
|
||||
return defaultErrorMessage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { LastCommentsConfig } from '@app/common/config-types';
|
||||
export function getLocale(params: { [key: string]: string } | LastCommentsConfig): string {
|
||||
return params.locale || 'en';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** this is generated file by "npm run generate-langs" **/
|
||||
// it is ok that is empty. Default messages from code will be used.
|
||||
const enMessages = {};
|
||||
|
||||
export async function loadLocale(locale: string): Promise<Record<string, string>> {
|
||||
if (locale === 'ru') {
|
||||
return import(/* webpackChunkName: "ru" */ '../locales/ru.json')
|
||||
.then(res => res.default)
|
||||
.catch(() => enMessages);
|
||||
}
|
||||
if (locale === 'de') {
|
||||
return import(/* webpackChunkName: "de" */ '../locales/de.json')
|
||||
.then(res => res.default)
|
||||
.catch(() => enMessages);
|
||||
}
|
||||
|
||||
return enMessages;
|
||||
}
|
||||
+2
-1
@@ -126,7 +126,8 @@
|
||||
host: window.location.origin,
|
||||
url: 'https://remark42.com/demo/',
|
||||
components: ['embed', 'counter'],
|
||||
theme: theme
|
||||
theme: theme,
|
||||
// locale: "ru"
|
||||
};
|
||||
|
||||
(function(c, d) {
|
||||
|
||||
@@ -15,4 +15,5 @@ module.exports = {
|
||||
'^react-dom$': 'preact/compat',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/app/testUtils/index.ts'],
|
||||
transformIgnorePatterns: ['/node_modules/(?!intl-messageformat|intl-messageformat-parser).+\\.js$'],
|
||||
};
|
||||
|
||||
Generated
+713
-6
@@ -171,6 +171,164 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.8.3.tgz",
|
||||
"integrity": "sha512-qmp4pD7zeTxsv0JNecSBsEmG1ei2MqwJq4YQcK3ZWm/0t07QstWfvuV/vm3Qt5xNMFETn2SZqpMx2MQzbtq+KA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-function-name": "^7.8.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.8.3",
|
||||
"@babel/helper-optimise-call-expression": "^7.8.3",
|
||||
"@babel/helper-plugin-utils": "^7.8.3",
|
||||
"@babel/helper-replace-supers": "^7.8.3",
|
||||
"@babel/helper-split-export-declaration": "^7.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
|
||||
"integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.8.4.tgz",
|
||||
"integrity": "sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz",
|
||||
"integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.8.3",
|
||||
"@babel/template": "^7.8.3",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz",
|
||||
"integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz",
|
||||
"integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-optimise-call-expression": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz",
|
||||
"integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-replace-supers": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz",
|
||||
"integrity": "sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-member-expression-to-functions": "^7.8.3",
|
||||
"@babel/helper-optimise-call-expression": "^7.8.3",
|
||||
"@babel/traverse": "^7.8.3",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz",
|
||||
"integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz",
|
||||
"integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.0.0",
|
||||
"esutils": "^2.0.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz",
|
||||
"integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz",
|
||||
"integrity": "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/parser": "^7.8.3",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.8.4.tgz",
|
||||
"integrity": "sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/generator": "^7.8.4",
|
||||
"@babel/helper-function-name": "^7.8.3",
|
||||
"@babel/helper-split-export-declaration": "^7.8.3",
|
||||
"@babel/parser": "^7.8.4",
|
||||
"@babel/types": "^7.8.3",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.8.3.tgz",
|
||||
"integrity": "sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"esutils": "^2.0.2",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/helper-define-map": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.5.5.tgz",
|
||||
@@ -507,6 +665,24 @@
|
||||
"@babel/plugin-syntax-async-generators": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-class-properties": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.8.3.tgz",
|
||||
"integrity": "sha512-EqFhbo7IosdgPgZggHaNObkmO1kNUe3slaKu54d5OWvy+p9QIKOzK1GAEpAIsZtWVtPXUHSMcT4smvDrCfY4AA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.8.3",
|
||||
"@babel/helper-plugin-utils": "^7.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-dynamic-import": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz",
|
||||
@@ -547,6 +723,24 @@
|
||||
"@babel/plugin-syntax-optional-catch-binding": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-optional-chaining": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.8.3.tgz",
|
||||
"integrity": "sha512-QIoIR9abkVn+seDE3OjA08jWcs3eZ9+wJCKSRgo3WdEU2csFYgdScb+8qHB3+WXsGJD55u+5hWCISI7ejXS+kg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.8.3",
|
||||
"@babel/plugin-syntax-optional-chaining": "^7.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-unicode-property-regex": {
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.6.2.tgz",
|
||||
@@ -612,6 +806,40 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-optional-chaining": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
|
||||
"integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-typescript": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.8.3.tgz",
|
||||
"integrity": "sha512-GO1MQ/SGGGoiEXY0e0bSpHimJvxqB7lktLLIq2pv8xG7WZ8IMEle74jIe1FhprHBWjwjZtXHkycDLZXIWM5Wfg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
|
||||
@@ -949,6 +1177,25 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-typescript": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.8.3.tgz",
|
||||
"integrity": "sha512-Ebj230AxcrKGZPKIp4g4TdQLrqX95TobLUWKd/CwG7X1XHUH1ZpkpFvXuXqWbtGRWb7uuEWNlrl681wsOArAdQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.8.3",
|
||||
"@babel/helper-plugin-utils": "^7.8.3",
|
||||
"@babel/plugin-syntax-typescript": "^7.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-unicode-regex": {
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.6.2.tgz",
|
||||
@@ -1044,6 +1291,24 @@
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.8.3.tgz",
|
||||
"integrity": "sha512-qee5LgPGui9zQ0jR1TeU5/fP9L+ovoArklEqY12ek8P/wV5ZeM/VYSQYwICeoT6FfpJTekG9Ilay5PhwsOpMHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.8.3",
|
||||
"@babel/plugin-transform-typescript": "^7.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
|
||||
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
|
||||
@@ -1137,6 +1402,93 @@
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"@formatjs/cli": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-1.1.20.tgz",
|
||||
"integrity": "sha512-0seqDTfTTzXikRYic5arhKI27b4sDpdtGsbA/WG5BMPUYRdGZ38xBUD7V8pueFXlGiIR+SVKqY2kHP0X3Y5b3Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.5",
|
||||
"@babel/plugin-proposal-optional-chaining": "7",
|
||||
"@babel/preset-env": "^7.6.0",
|
||||
"@babel/preset-typescript": "^7.6.0",
|
||||
"@types/babel__core": "^7.1.3",
|
||||
"@types/loader-utils": "^1.1.3",
|
||||
"@types/lodash": "^4.14.138",
|
||||
"babel-plugin-const-enum": "^0.0.2",
|
||||
"babel-plugin-react-intl": "^5.1.18",
|
||||
"commander": "4.0.0-1",
|
||||
"fs-extra": "^8.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"loader-utils": "^1.2.3",
|
||||
"lodash": "^4.17.15",
|
||||
"loud-rejection": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "4.0.0-1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.0.0-1.tgz",
|
||||
"integrity": "sha512-6UCnFDyJnZfk4ZugvEhl8hYtlViGJNVki5J+3x/zNd8nLDpBcDHZShvBYWKIIktsiBUuRDttP/qG1yF+bhhVnQ==",
|
||||
"dev": true
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-displaynames": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-1.2.0.tgz",
|
||||
"integrity": "sha512-mUGI2sc6OABkrMj42HlOpK1h96EVrN+gOhzbyCTMH9SVH/gPPLr/zFRH3KFWtBwxqhYsDghvUwm8xkdFOK0kTg==",
|
||||
"requires": {
|
||||
"@formatjs/intl-utils": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-listformat": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-1.4.1.tgz",
|
||||
"integrity": "sha512-AX0o1y5xXyMY4ebZOO+UujMcDhniYDs50KpwGzjUPV+bBILwRYqH/6IprZZG/V8YSOtetZlalZiwzJ50dH6PuQ==",
|
||||
"requires": {
|
||||
"@formatjs/intl-utils": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-relativetimeformat": {
|
||||
"version": "4.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-4.5.9.tgz",
|
||||
"integrity": "sha512-6rgPXQl5MrPPbCuNiHxolzO6xNCHphCVEWW6RWGy7t/Mek70gD7nq1erW8fbQJ0XL/UeAC0Cz/+ggh7vaSsKNA==",
|
||||
"requires": {
|
||||
"@formatjs/intl-utils": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-unified-numberformat": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.2.0.tgz",
|
||||
"integrity": "sha512-SZMTV/tR0h7nYhS2x69S7zhHXaBmE0ZTR2OIiakt8W7uYWVgcRhu/LgUeVtGzpwPI2ChcOjNMtX/k6y1M9aDNA==",
|
||||
"requires": {
|
||||
"@formatjs/intl-utils": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-utils": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.2.0.tgz",
|
||||
"integrity": "sha512-+Az7tR1av1DHZu9668D8uh9atT6vp+FFmEF8BrEssv0OqzpVjpVBGVmcgPzQP8k2PQjVlm/h2w8cTt0knn132w=="
|
||||
},
|
||||
"@formatjs/macro": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/macro/-/macro-0.2.6.tgz",
|
||||
"integrity": "sha512-DfdnLJf8+PwLHzJECZ1Xfa8+sI9akQnUuLN2UdkaExTQmlY0Vs36rMzEP0JoVDBMk+KdQbJNt72rPeZkBNcKWg=="
|
||||
},
|
||||
"@github/combobox-nav": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-1.0.1.tgz",
|
||||
@@ -1857,12 +2209,16 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
|
||||
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*",
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"@types/invariant": {
|
||||
"version": "2.2.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.31.tgz",
|
||||
"integrity": "sha512-jMlgg9pIURvy9jgBHCjQp/CyBjYHUwj91etVcDdXkFl2CwTFiQlB+8tcsMeXpXf2PFE5X2pjk4Gm43hQSMHAdA=="
|
||||
},
|
||||
"@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
|
||||
@@ -1909,6 +2265,16 @@
|
||||
"integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/loader-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"@types/webpack": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.144",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.144.tgz",
|
||||
@@ -1962,14 +2328,12 @@
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "16.9.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.11.tgz",
|
||||
"integrity": "sha512-UBT4GZ3PokTXSWmdgC/GeCGEJXE5ofWyibCcecRLUVN2ZBpXQGVgQGtG2foS7CrTKFKlQVVswLvf7Js6XA/CVQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^2.2.0"
|
||||
@@ -1996,6 +2360,12 @@
|
||||
"redux": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/schema-utils": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/schema-utils/-/schema-utils-1.0.0.tgz",
|
||||
"integrity": "sha512-YesPanU1+WCigC/Aj1Mga8UCOjHIfMNHZ3zzDsUY7lI8GlKnh/Kv2QwJOQ+jNQ36Ru7IfzSedlG14hppYaN13A==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/stack-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
|
||||
@@ -2554,6 +2924,12 @@
|
||||
"integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=",
|
||||
"dev": true
|
||||
},
|
||||
"array-find-index": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
|
||||
"integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=",
|
||||
"dev": true
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -2972,6 +3348,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"babel-plugin-const-enum": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-const-enum/-/babel-plugin-const-enum-0.0.2.tgz",
|
||||
"integrity": "sha512-jTa4A/b2sTM++neJnNZRi6CL7imluFkepD7mB+IpndQy/5LKwPpuoIfSY0nC94Y/nnxjhNRAW2fdBgT5dI4/+w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@babel/plugin-syntax-typescript": "^7.3.3"
|
||||
}
|
||||
},
|
||||
"babel-plugin-dynamic-import-node": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz",
|
||||
@@ -3002,6 +3388,206 @@
|
||||
"@types/babel__traverse": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"babel-plugin-react-intl": {
|
||||
"version": "5.1.18",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-intl/-/babel-plugin-react-intl-5.1.18.tgz",
|
||||
"integrity": "sha512-tzzZoGDNQOiHmGFh+NPQJDpC10RbKlfw1CBVfALulqRa6UGkAv5eMs9sirxjhD3HryHPbYZ4x5FNdbzOyG2GJw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/core": "^7.7.2",
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@types/babel__core": "^7.1.3",
|
||||
"@types/schema-utils": "^1.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"intl-messageformat-parser": "^3.6.4",
|
||||
"schema-utils": "^2.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
|
||||
"integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/core": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.8.4.tgz",
|
||||
"integrity": "sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/generator": "^7.8.4",
|
||||
"@babel/helpers": "^7.8.4",
|
||||
"@babel/parser": "^7.8.4",
|
||||
"@babel/template": "^7.8.3",
|
||||
"@babel/traverse": "^7.8.4",
|
||||
"@babel/types": "^7.8.3",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.1",
|
||||
"json5": "^2.1.0",
|
||||
"lodash": "^4.17.13",
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.4.1",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.8.4.tgz",
|
||||
"integrity": "sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz",
|
||||
"integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.8.3",
|
||||
"@babel/template": "^7.8.3",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz",
|
||||
"integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz",
|
||||
"integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/helpers": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.8.4.tgz",
|
||||
"integrity": "sha512-VPbe7wcQ4chu4TDQjimHv/5tj73qz88o12EPkO2ValS2QiQS/1F2SsjyIGNnAD0vF/nZS6Cf9i+vW6HIlnaR8w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/template": "^7.8.3",
|
||||
"@babel/traverse": "^7.8.4",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz",
|
||||
"integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.0.0",
|
||||
"esutils": "^2.0.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz",
|
||||
"integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz",
|
||||
"integrity": "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/parser": "^7.8.3",
|
||||
"@babel/types": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.8.4.tgz",
|
||||
"integrity": "sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/generator": "^7.8.4",
|
||||
"@babel/helper-function-name": "^7.8.3",
|
||||
"@babel/helper-split-export-declaration": "^7.8.3",
|
||||
"@babel/parser": "^7.8.4",
|
||||
"@babel/types": "^7.8.3",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.8.3.tgz",
|
||||
"integrity": "sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"esutils": "^2.0.2",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"ajv": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz",
|
||||
"integrity": "sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"ajv-keywords": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz",
|
||||
"integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==",
|
||||
"dev": true
|
||||
},
|
||||
"convert-source-map": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
|
||||
"integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "~5.1.1"
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
|
||||
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==",
|
||||
"dev": true
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.4.tgz",
|
||||
"integrity": "sha512-VNjcaUxVnEeun6B2fiiUDjXXBtD4ZSH7pdbfIu1pOFwgptDPLMo/z9jr4sUfsjFVPqDCEin/F7IYlq7/E6yDbQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^6.10.2",
|
||||
"ajv-keywords": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"babel-polyfill": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
|
||||
@@ -4537,8 +5123,7 @@
|
||||
"csstype": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
|
||||
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
|
||||
},
|
||||
"cuint": {
|
||||
"version": "0.2.2",
|
||||
@@ -4546,6 +5131,15 @@
|
||||
"integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=",
|
||||
"dev": true
|
||||
},
|
||||
"currently-unhandled": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
|
||||
"integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"array-find-index": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
|
||||
@@ -6263,6 +6857,25 @@
|
||||
"readable-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"graceful-fs": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
|
||||
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"fs-write-stream-atomic": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
|
||||
@@ -6931,6 +7544,12 @@
|
||||
"lodash.padstart": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"gensync": {
|
||||
"version": "1.0.0-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz",
|
||||
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
|
||||
"dev": true
|
||||
},
|
||||
"get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -7883,6 +8502,33 @@
|
||||
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.7.0.tgz",
|
||||
"integrity": "sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg=="
|
||||
},
|
||||
"intl-format-cache": {
|
||||
"version": "4.2.21",
|
||||
"resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-4.2.21.tgz",
|
||||
"integrity": "sha512-6pZlBdqTRUuuwRWywPItHY1JQwzQxWcpBHv6w4M8T6bGzAsiL/QmI+XsdOhsqJLaL4ZmTATn1kIkNlMk4VzSLQ=="
|
||||
},
|
||||
"intl-locales-supported": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/intl-locales-supported/-/intl-locales-supported-1.8.4.tgz",
|
||||
"integrity": "sha512-wO0JhDqhshhkq8Pa9CLcstqd1aCXjfMgfMzjD6mDreS3mTSDbjGiMU+07O8BdJGxed7Q0Wf3TFVjGq0W3Y0n1w=="
|
||||
},
|
||||
"intl-messageformat": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-7.8.4.tgz",
|
||||
"integrity": "sha512-yS0cLESCKCYjseCOGXuV4pxJm/buTfyCJ1nzQjryHmSehlptbZbn9fnlk1I9peLopZGGbjj46yHHiTAEZ1qOTA==",
|
||||
"requires": {
|
||||
"intl-format-cache": "^4.2.21",
|
||||
"intl-messageformat-parser": "^3.6.4"
|
||||
}
|
||||
},
|
||||
"intl-messageformat-parser": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat-parser/-/intl-messageformat-parser-3.6.4.tgz",
|
||||
"integrity": "sha512-RgPGwue0mJtoX2Ax8EmMzJzttxjnva7gx0Q7mKJ4oALrTZvtmCeAw5Msz2PcjW4dtCh/h7vN/8GJCxZO1uv+OA==",
|
||||
"requires": {
|
||||
"@formatjs/intl-unified-numberformat": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -9982,6 +10628,15 @@
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"jsonify": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
|
||||
@@ -10790,6 +11445,16 @@
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"loud-rejection": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-2.2.0.tgz",
|
||||
"integrity": "sha512-S0FayMXku80toa5sZ6Ro4C+s+EtFDCsyJNG/AzFMfX3AxD5Si4dZsgzm/kKnbOxHl5Cv8jBlno8+3XYIh2pNjQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"currently-unhandled": "^0.4.1",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"lower-case": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz",
|
||||
@@ -13156,6 +13821,37 @@
|
||||
"unpipe": "1.0.0"
|
||||
}
|
||||
},
|
||||
"react-intl": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-3.12.0.tgz",
|
||||
"integrity": "sha512-VQWkFYSKKoi85p3gOXgG80KkBImdBJXwJxssO9gqdelW/fuVnxQLXgYOKuOqWrUz5beXK+qBve6bTpblh1ep2g==",
|
||||
"requires": {
|
||||
"@formatjs/intl-displaynames": "^1.2.0",
|
||||
"@formatjs/intl-listformat": "^1.3.7",
|
||||
"@formatjs/intl-relativetimeformat": "^4.5.7",
|
||||
"@formatjs/intl-unified-numberformat": "^3.0.4",
|
||||
"@formatjs/intl-utils": "^2.0.4",
|
||||
"@formatjs/macro": "^0.2.6",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"@types/invariant": "^2.2.31",
|
||||
"hoist-non-react-statics": "^3.3.1",
|
||||
"intl-format-cache": "^4.2.19",
|
||||
"intl-locales-supported": "^1.8.4",
|
||||
"intl-messageformat": "^7.8.2",
|
||||
"intl-messageformat-parser": "^3.6.2",
|
||||
"shallow-equal": "^1.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"requires": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.8.6",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
|
||||
@@ -13974,6 +14670,11 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"shallow-equal": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
|
||||
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
||||
@@ -16291,6 +16992,12 @@
|
||||
"unist-util-is": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true
|
||||
},
|
||||
"unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"start": "webpack-dev-server --mode development",
|
||||
"check": "tsc -p tsconfig.json --noEmit --skipLibCheck",
|
||||
"lint": "eslint --max-warnings=0 --ext=.ts,.tsx,.js,.jsx .",
|
||||
"lint:style": "stylelint \"**/*.scss\" \"**/*.pcss\" \"**/*.css\" \"iframe.html\"",
|
||||
"lint:style": "stylelint '**/*.scss' '**/*.pcss' '**/*.css' 'iframe.html'",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage",
|
||||
"prettier": "prettier --write \"./**/*.{js,jsx,ts,tsx,scss}\"",
|
||||
"prettier": "prettier --write './**/*.{js,jsx,ts,tsx,scss}'",
|
||||
"extract-messages": "formatjs extract --out-file=./extracted-messages/messages.json './app/**/*.{js,jsx,ts,tsx}'",
|
||||
"generate-langs": "npm run extract-messages && node ./tasks/generateDictionary.js",
|
||||
"size": "NODE_ENV=production npm run build && size-limit"
|
||||
},
|
||||
"husky": {
|
||||
@@ -25,6 +27,7 @@
|
||||
"@babel/plugin-transform-react-jsx": "^7.3.0",
|
||||
"@babel/preset-env": "^7.6.3",
|
||||
"@babel/preset-react": "^7.6.3",
|
||||
"@formatjs/cli": "^1.1.20",
|
||||
"@size-limit/file": "^4.0.1",
|
||||
"@types/cheerio": "^0.22.13",
|
||||
"@types/core-js": "^2.5.2",
|
||||
@@ -107,6 +110,7 @@
|
||||
"lodash-es": "^4.17.15",
|
||||
"node-emoji": "^1.10.0",
|
||||
"preact": "^10.0.1",
|
||||
"react-intl": "^3.12.0",
|
||||
"react-redux": "^7.1.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-thunk": "^2.3.0"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const defaultMessages = require('../extracted-messages/messages');
|
||||
const { renderLoadLocale } = require('./localeLoadTemplate');
|
||||
const { getSupportedLocales } = require('./getSupportedLocales');
|
||||
|
||||
const locales = getSupportedLocales();
|
||||
|
||||
const keyMessagePairs = [];
|
||||
const keysSet = new Set();
|
||||
defaultMessages.forEach(({ id, defaultMessage }) => {
|
||||
keyMessagePairs.push([id, defaultMessage]);
|
||||
keysSet.add(id);
|
||||
});
|
||||
|
||||
function removeAbandonedKeys(existKeys, dictionary) {
|
||||
return Object.fromEntries(Object.entries(dictionary).filter(([key]) => existKeys.has(key)));
|
||||
}
|
||||
|
||||
function sortDict(dict) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(dict).sort(([a], [b]) => {
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
locales.forEach(locale => {
|
||||
let currentDict = {};
|
||||
const pathToDict = path.resolve(__dirname, `../app/locales/${locale}.json`);
|
||||
if (fs.existsSync(pathToDict)) {
|
||||
currentDict = require(pathToDict);
|
||||
}
|
||||
keyMessagePairs.forEach(([key, defaultMessage]) => {
|
||||
if (!currentDict[key] || locale === `en`) {
|
||||
currentDict[key] = defaultMessage;
|
||||
}
|
||||
});
|
||||
currentDict = removeAbandonedKeys(keysSet, currentDict);
|
||||
currentDict = sortDict(currentDict);
|
||||
fs.writeFileSync(pathToDict, JSON.stringify(currentDict, null, 2) + '\n');
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, `../app/utils/loadLocale.ts`),
|
||||
renderLoadLocale(locales.filter(locale => locale !== 'en'))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
const getSupportedLocales = require('./supportedLocales');
|
||||
module.exports = {
|
||||
getSupportedLocales() {
|
||||
return getSupportedLocales;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
function renderLoadLocale(locales) {
|
||||
return `/** this is generated file by "npm run generate-langs" **/
|
||||
// it is ok that is empty. Default messages from code will be used.
|
||||
const enMessages = {};
|
||||
|
||||
export async function loadLocale(locale: string): Promise<Record<string, string>> {
|
||||
${locales
|
||||
.map(
|
||||
locale => ` if (locale === '${locale}') {
|
||||
return import(/* webpackChunkName: "${locale}" */ '../locales/${locale}.json')
|
||||
.then(res => res.default)
|
||||
.catch(() => enMessages);
|
||||
}
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
return enMessages;
|
||||
}\n`;
|
||||
}
|
||||
|
||||
module.exports = { renderLoadLocale };
|
||||
@@ -0,0 +1 @@
|
||||
["en", "ru", "de"]
|
||||
@@ -16,6 +16,7 @@
|
||||
"baseUrl": "./",
|
||||
"alwaysStrict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"*": ["@types/*"],
|
||||
|
||||
@@ -27,7 +27,14 @@ console.log(`REMARK_ENV = ${remarkUrl}`);
|
||||
* so we have to exclude from ignore these modules
|
||||
*/
|
||||
function getExcluded() {
|
||||
const modules = ['@github/markdown-toolbar-element', '@github/text-expander-element', '@github/combobox-nav'];
|
||||
const modules = [
|
||||
'@github/markdown-toolbar-element',
|
||||
'@github/text-expander-element',
|
||||
'@github/combobox-nav',
|
||||
'react-intl',
|
||||
'intl-messageformat',
|
||||
'intl-messageformat-parser',
|
||||
];
|
||||
const exclude = new RegExp(`node_modules\\/(?!(${modules.map(m => m.replace(/\//g, '\\/')).join('|')})\\/).*`);
|
||||
|
||||
return {
|
||||
@@ -203,7 +210,7 @@ module.exports = () => ({
|
||||
},
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 9000,
|
||||
port: process.env.PORT || 9000,
|
||||
contentBase: publicFolder,
|
||||
publicPath: '/web',
|
||||
disableHostCheck: true,
|
||||
|
||||
Reference in New Issue
Block a user