new-auth
@@ -9,7 +9,7 @@ module.exports = [
|
||||
},
|
||||
{
|
||||
path: 'public/remark.css',
|
||||
limit: '9 KB',
|
||||
limit: '10 KB',
|
||||
},
|
||||
{
|
||||
path: 'public/last-comments.mjs',
|
||||
|
||||
@@ -12,6 +12,8 @@ module.exports = {
|
||||
ignore: ['after-comment'],
|
||||
},
|
||||
],
|
||||
'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global'] }],
|
||||
'property-no-unknown': [true, { ignoreProperties: ['composes'] }],
|
||||
'mavrin/stylelint-declaration-use-css-custom-properties': {
|
||||
cssDefinitions: ['color'],
|
||||
ignoreProperties: ['/^\\$/'],
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
jest.mock('./settings', () => ({
|
||||
siteId: 'remark',
|
||||
}));
|
||||
|
||||
import { logIn } from './api';
|
||||
|
||||
describe('api', () => {
|
||||
it('should open oauth endpoint with right url', () => {
|
||||
window.open = jest.fn().mockImplementationOnce(jest.fn());
|
||||
logIn({ name: 'google' });
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,7 @@
|
||||
import { siteId, url } from './settings';
|
||||
import { BASE_URL, API_BASE } from './constants';
|
||||
import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL, Image } from './types';
|
||||
import { authFetcher, apiFetcher, adminFetcher } from './fetcher';
|
||||
|
||||
/* Auth methods */
|
||||
const FROM_URL = `${window.location.origin}${window.location.pathname}?selfClose`;
|
||||
|
||||
const __loginAnonymously = (username: string): Promise<User | null> =>
|
||||
authFetcher.get<User>('/anonymous/login', {
|
||||
user: username,
|
||||
aud: siteId,
|
||||
from: FROM_URL,
|
||||
});
|
||||
|
||||
const __loginViaEmail = (token: string): Promise<User | null> => authFetcher.get<User>('/email/login', { token });
|
||||
|
||||
/**
|
||||
* First step of two of `email` authorization
|
||||
*
|
||||
* @param username userrname
|
||||
* @param address email address
|
||||
*/
|
||||
export const sendEmailVerificationRequest = (username: string, address: string): Promise<void> =>
|
||||
authFetcher.get('/email/login', { id: siteId, user: username, address });
|
||||
|
||||
export const logIn = (provider: AuthProvider): Promise<User | null> => {
|
||||
if (provider.name === 'anonymous') return __loginAnonymously(provider.username);
|
||||
if (provider.name === 'email') return __loginViaEmail(provider.token);
|
||||
|
||||
return new Promise<User | null>((resolve, reject) => {
|
||||
const queryString = new URLSearchParams({ from: FROM_URL, site: siteId });
|
||||
const newWindow = window.open(`/auth/${provider.name}/login?${queryString}`);
|
||||
let secondsPass = 0;
|
||||
const checkMsDelay = 300;
|
||||
const checkInterval = setInterval(() => {
|
||||
let shouldProceed;
|
||||
secondsPass += checkMsDelay;
|
||||
try {
|
||||
shouldProceed = (newWindow && newWindow.closed) || secondsPass > 30000;
|
||||
} catch (e) {}
|
||||
|
||||
if (shouldProceed) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
getUser()
|
||||
.then((user) => {
|
||||
resolve(user);
|
||||
})
|
||||
.catch(() => {
|
||||
reject(new Error('User logIn Error'));
|
||||
});
|
||||
}
|
||||
}, checkMsDelay);
|
||||
});
|
||||
};
|
||||
|
||||
export const logOut = (): Promise<void> => authFetcher.get('/logout');
|
||||
import { Config, Comment, Tree, User, BlockedUser, Sorting, BlockTTL, Image } from './types';
|
||||
import { apiFetcher, adminFetcher } from './fetcher';
|
||||
|
||||
/* API methods */
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Sorting, AuthProvider, Theme } from './types';
|
||||
import { Sorting, Theme } from './types';
|
||||
|
||||
export { BASE_URL, API_BASE, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from './constants.config';
|
||||
export const LAST_COMMENTS_NODE_CLASSNAME = 'remark42__last-comments';
|
||||
@@ -6,18 +6,6 @@ export const MAX_SHOWN_ROOT_COMMENTS = 10;
|
||||
|
||||
export const DEFAULT_SORT: Sorting = '-active';
|
||||
|
||||
/* matches auth providers to UI label */
|
||||
export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = {
|
||||
google: 'Google',
|
||||
twitter: 'Twitter',
|
||||
facebook: 'Facebook',
|
||||
github: 'GitHub',
|
||||
yandex: 'Yandex',
|
||||
dev: 'Dev',
|
||||
anonymous: 'Anonymous',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
/** locastorage key for collapsed comments */
|
||||
export const LS_COLLAPSE_KEY = '__remarkCollapsed';
|
||||
|
||||
|
||||
@@ -100,13 +100,17 @@ export interface Tree {
|
||||
info: PostInfo;
|
||||
}
|
||||
|
||||
export type OAuthProvider = 'facebook' | 'twitter' | 'google' | 'yandex' | 'github' | 'microsoft' | 'dev';
|
||||
export type FormProvider = 'email' | 'anonymous';
|
||||
export type Provider = OAuthProvider | FormProvider;
|
||||
|
||||
export interface Config {
|
||||
version: string;
|
||||
auth_providers: Provider[];
|
||||
edit_duration: number;
|
||||
max_comment_size: number;
|
||||
admins: string[];
|
||||
admin_email: string;
|
||||
auth_providers: AuthProvider['name'][];
|
||||
low_score: number;
|
||||
critical_score: number;
|
||||
positive_score: boolean;
|
||||
@@ -120,16 +124,6 @@ export interface Config {
|
||||
|
||||
export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy';
|
||||
|
||||
export type AuthProvider =
|
||||
| { name: 'google' }
|
||||
| { name: 'facebook' }
|
||||
| { name: 'github' }
|
||||
| { name: 'yandex' }
|
||||
| { name: 'twitter' }
|
||||
| { name: 'dev' }
|
||||
| { name: 'anonymous'; username: string }
|
||||
| { name: 'email'; token: string };
|
||||
|
||||
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m';
|
||||
|
||||
export interface BlockingDuration {
|
||||
|
||||
@@ -4,5 +4,4 @@
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,8 @@ import type { User } from 'common/types';
|
||||
import enMessages from 'locales/en.json';
|
||||
|
||||
import AuthPanel, { Props } from './auth-panel';
|
||||
import { Button } from '../button';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
|
||||
const DefaultProps = {
|
||||
providers: ['google', 'github'],
|
||||
provider: { name: null },
|
||||
postInfo: {
|
||||
read_only: false,
|
||||
url: 'https://example.com',
|
||||
@@ -69,24 +65,6 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
expect(adminAction.text()).toEqual('Show settings');
|
||||
});
|
||||
|
||||
it('should render auth for read only post', () => {
|
||||
StaticStore.config.auth_providers = ['google', 'github'];
|
||||
|
||||
const element = createWrapper({
|
||||
...DefaultProps,
|
||||
user: null,
|
||||
postInfo: { ...DefaultProps.postInfo, read_only: true },
|
||||
hiddenUsers: { hidden_joe: {} as User },
|
||||
} as Props);
|
||||
|
||||
const firstCol = element.find('.auth-panel__column').first();
|
||||
const providerButtons = firstCol.find(Button);
|
||||
|
||||
expect(firstCol.text().startsWith('Login:')).toBe(true);
|
||||
expect(providerButtons.at(0).text()).toBe('Google');
|
||||
expect(providerButtons.at(1).text()).toBe('GitHub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('For authorized user', () => {
|
||||
|
||||
@@ -3,19 +3,17 @@ import { useSelector } from 'react-redux';
|
||||
import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { User, AuthProvider, Sorting, Theme, PostInfo } from 'common/types';
|
||||
import { User, Sorting, Theme, PostInfo } from 'common/types';
|
||||
import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants';
|
||||
import { requestDeletion } from 'utils/email';
|
||||
import postMessage from 'utils/postMessage';
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
import { StoreState } from 'store';
|
||||
import { ProviderState } from 'store/provider/reducers';
|
||||
import { Dropdown, DropdownItem } from 'components/dropdown';
|
||||
import { Button } from 'components/button';
|
||||
import Auth from 'components/auth';
|
||||
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
|
||||
export interface OwnProps {
|
||||
user: User | null;
|
||||
@@ -24,7 +22,6 @@ export interface OwnProps {
|
||||
postInfo: PostInfo;
|
||||
|
||||
onSortChange(s: Sorting): Promise<void>;
|
||||
onSignIn(p: AuthProvider): Promise<User | null>;
|
||||
onSignOut(): Promise<void>;
|
||||
onCommentsChangeReadOnlyMode(readOnly: boolean): Promise<void>;
|
||||
onBlockedUsersShow(): void;
|
||||
@@ -34,8 +31,6 @@ export interface OwnProps {
|
||||
export interface Props extends OwnProps {
|
||||
intl: IntlShape;
|
||||
theme: Theme;
|
||||
providers: AuthProvider['name'][];
|
||||
provider: ProviderState;
|
||||
sort: Sorting;
|
||||
}
|
||||
|
||||
@@ -327,17 +322,7 @@ function getSortArray(currentSort: Sorting, intl: IntlShape) {
|
||||
export default function AuthPanelConnected(props: OwnProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const provider = useSelector<StoreState, ProviderState>((state) => state.provider);
|
||||
const sort = useSelector<StoreState, Sorting>((state) => state.comments.sort);
|
||||
|
||||
return (
|
||||
<AuthPanel
|
||||
intl={intl}
|
||||
theme={theme}
|
||||
providers={StaticStore.config.auth_providers}
|
||||
provider={provider}
|
||||
sort={sort}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <AuthPanel intl={intl} theme={theme} sort={sort} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
.auth-anonymous-login-form {
|
||||
padding: 0.5em 0.7em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.auth-anonymous-login-form__input {
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.auth-anonymous-login-form__remember-me {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-anonymous-login-form__submit {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { h, Component, createRef } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
import { IntlShape, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { Theme } from 'common/types';
|
||||
|
||||
import { Input } from 'components/input';
|
||||
import { Button } from 'components/button';
|
||||
|
||||
import { validateUserName } from '../validateUserName';
|
||||
|
||||
interface Props {
|
||||
onSubmit(username: string): Promise<void>;
|
||||
theme: Theme;
|
||||
className?: string;
|
||||
intl: IntlShape;
|
||||
}
|
||||
|
||||
interface State {
|
||||
inputValue: string;
|
||||
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 contain only letters, numbers, underscores or spaces',
|
||||
},
|
||||
userName: {
|
||||
id: 'anonymousLoginForm.user-name',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
});
|
||||
|
||||
export class AnonymousLoginForm extends Component<Props, State> {
|
||||
inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
inputValue: '',
|
||||
honeyPotValue: false,
|
||||
};
|
||||
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onCheckedChange = this.onCheckedChange.bind(this);
|
||||
}
|
||||
|
||||
onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (this.state.honeyPotValue) {
|
||||
// what should i do if bot uncovered?
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this.props.onSubmit(this.state.inputValue);
|
||||
}
|
||||
|
||||
onChange(e: Event) {
|
||||
this.setState({ inputValue: (e.target as HTMLInputElement).value });
|
||||
}
|
||||
|
||||
getUsernameInvalidReason(): string | null {
|
||||
const value = this.state.inputValue;
|
||||
const intl = this.props.intl;
|
||||
if (value.length < 3) return intl.formatMessage(messages.lengthLimit);
|
||||
if (!validateUserName(value)) return intl.formatMessage(messages.symbolLimit);
|
||||
return null;
|
||||
}
|
||||
|
||||
onCheckedChange(e: Event) {
|
||||
this.setState({ honeyPotValue: (e.target as HTMLInputElement).checked });
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
setTimeout(() => {
|
||||
this.inputRef.current && this.inputRef.current.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
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-anonymous-login-form', {}, { theme: props.theme });
|
||||
if (props.className) {
|
||||
className += ` ${b('auth-anonymous-login-form', {}, { theme: props.theme })}`;
|
||||
}
|
||||
|
||||
const usernameInvalidReason = this.getUsernameInvalidReason();
|
||||
|
||||
return (
|
||||
<form className={className} onSubmit={this.onSubmit}>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
mix="auth-anonymous-login-form__input"
|
||||
placeholder={intl.formatMessage(messages.userName)}
|
||||
value={this.state.inputValue}
|
||||
onInput={this.onChange}
|
||||
/>
|
||||
{/* honeypot input */}
|
||||
<input
|
||||
className="auth-anonymous-login-form__remember-me"
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
autocomplete="off"
|
||||
onChange={this.onCheckedChange}
|
||||
checked={this.state.honeyPotValue}
|
||||
/>
|
||||
<Button
|
||||
mix="auth-anonymous-login-form__submit"
|
||||
type="submit"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
title={usernameInvalidReason || ''}
|
||||
disabled={usernameInvalidReason !== null}
|
||||
>
|
||||
<FormattedMessage id="anonymousLoginForm.log-in" defaultMessage="Log in" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import './auth__anonymous-login-form.css';
|
||||
|
||||
export { AnonymousLoginForm } from './auth__anonymous-login-form';
|
||||
@@ -1,61 +0,0 @@
|
||||
.auth-email-login-form {
|
||||
padding: 0.35em 0.55em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.auth-email-login-form__input,
|
||||
.auth-email-login-form__token-input {
|
||||
width: 12rem;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.auth-email-login-form__token-input {
|
||||
resize: vertical;
|
||||
border: 1px solid var(--color31);
|
||||
padding: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--color47);
|
||||
border-color: var(--color15);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-email-login-form__submit {
|
||||
margin: 0.3rem 2px 2px;
|
||||
}
|
||||
|
||||
.auth-email-login-form__back-button {
|
||||
text-align: left;
|
||||
margin-left: 0.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&::before {
|
||||
content: '◄';
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-email-login-form__error {
|
||||
margin: 4px 2px;
|
||||
padding: 6px 8px;
|
||||
font-weight: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-email-login-form_theme_dark .auth-email-login-form__error {
|
||||
background: var(--color28);
|
||||
color: var(--color27);
|
||||
}
|
||||
|
||||
.auth-email-login-form_theme_light .auth-email-login-form__error {
|
||||
background: var(--color26);
|
||||
color: var(--color25);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth__email-login-form';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { validToken } from '__stubs__/jwt';
|
||||
import { LS_EMAIL_KEY } from 'common/constants';
|
||||
import { User } from 'common/types';
|
||||
import { sleep } from 'utils/sleep';
|
||||
import { sendEmailVerificationRequest } from 'common/api';
|
||||
import enMessages from 'locales/en.json';
|
||||
|
||||
jest.mock('utils/jwt', () => ({
|
||||
isJwtExpired: jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => true)
|
||||
.mockImplementationOnce(() => false)
|
||||
.mockImplementationOnce(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('common/api');
|
||||
|
||||
const sendEmailVerificationRequestMock = sendEmailVerificationRequest as jest.Mock<
|
||||
ReturnType<typeof sendEmailVerificationRequest>
|
||||
>;
|
||||
|
||||
function simulateInput(input: ReactWrapper, value: string) {
|
||||
input.getDOMNode<HTMLTextAreaElement>().value = value;
|
||||
input.simulate('input');
|
||||
}
|
||||
|
||||
describe('EmailLoginForm', () => {
|
||||
const testUser = {} as User;
|
||||
const onSuccess = jest.fn(async () => undefined);
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
beforeEach(() => {
|
||||
sendEmailVerificationRequestMock.mockReset();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
sendEmailVerificationRequestMock.mockResolvedValueOnce();
|
||||
const el = mount<Props, State>(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
|
||||
</IntlProvider>
|
||||
);
|
||||
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(sendEmailVerificationRequestMock).toBeCalledWith('someone', 'someone@example.com');
|
||||
el.update();
|
||||
simulateInput(el.find(`textarea[name="token"]`), 'abcd');
|
||||
|
||||
el.find('form').simulate('submit');
|
||||
await sleep(100);
|
||||
expect(onSignIn).toBeCalledWith('abcd');
|
||||
expect(onSuccess).toBeCalledWith(testUser);
|
||||
//test that email is saved in local storage after email login
|
||||
expect(localStorage.getItem(LS_EMAIL_KEY)).toEqual('someone@example.com');
|
||||
});
|
||||
|
||||
it('should send form by pasting token', async () => {
|
||||
sendEmailVerificationRequestMock.mockResolvedValueOnce();
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
const wrapper = mount<Props, State>(
|
||||
<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();
|
||||
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 () => {
|
||||
sendEmailVerificationRequestMock.mockResolvedValueOnce();
|
||||
const onSignIn = jest.fn(async () => testUser);
|
||||
|
||||
const wrapper = mount<Props, State>(
|
||||
<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');
|
||||
|
||||
expect(wrapper.find('.auth-email-login-form__error').text()).toBe('Token is expired');
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import { h, Component, createRef } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import b from 'bem-react-helper';
|
||||
import { Theme, User } from 'common/types';
|
||||
import { LS_EMAIL_KEY } from 'common/constants';
|
||||
import { sendEmailVerificationRequest } from 'common/api';
|
||||
import { extractErrorMessageFromResponse } from 'utils/errorUtils';
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
import { sleep } from 'utils/sleep';
|
||||
import TextareaAutosize from 'components/textarea-autosize';
|
||||
import { Input } from 'components/input';
|
||||
import { Button } from 'components/button';
|
||||
import { isJwtExpired } from 'utils/jwt';
|
||||
import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { validateUserName } from '../validateUserName';
|
||||
import { messages as loginForm } from '../__anonymous-login-form/auth__anonymous-login-form';
|
||||
|
||||
interface OwnProps {
|
||||
onSignIn(token: string): Promise<User | null>;
|
||||
onSuccess?(user: User): Promise<void>;
|
||||
theme: Theme;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & { intl: IntlShape; sendEmailVerification: typeof sendEmailVerificationRequest };
|
||||
|
||||
export interface State {
|
||||
usernameValue: string;
|
||||
addressValue: string;
|
||||
tokenValue: string;
|
||||
verificationSent: boolean;
|
||||
loading: boolean;
|
||||
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 emailRegex = /[^@]+@[^.]+\..+/;
|
||||
|
||||
usernameInputRef = createRef<HTMLInputElement>();
|
||||
tokenRef = createRef<HTMLTextAreaElement>();
|
||||
|
||||
state = {
|
||||
usernameValue: '',
|
||||
addressValue: '',
|
||||
tokenValue: '',
|
||||
verificationSent: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
focus = async () => {
|
||||
await sleep(100);
|
||||
|
||||
if (this.usernameInputRef.current) {
|
||||
this.usernameInputRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.tokenRef.current?.select();
|
||||
};
|
||||
|
||||
onVerificationSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
await this.props.sendEmailVerification(this.state.usernameValue, this.state.addressValue);
|
||||
this.setState({ verificationSent: true });
|
||||
await sleep(100);
|
||||
this.tokenRef.current?.focus();
|
||||
} catch (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: intl.formatMessage(messages.userNotFound) });
|
||||
return;
|
||||
}
|
||||
this.setState({ verificationSent: false, tokenValue: '' });
|
||||
localStorage.setItem(LS_EMAIL_KEY, this.state.addressValue);
|
||||
if (this.props.onSuccess) {
|
||||
await this.props.onSuccess(user);
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) });
|
||||
} finally {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
this.sendForm();
|
||||
};
|
||||
|
||||
onUsernameChange = (e: Event) => {
|
||||
this.setState({ error: null, usernameValue: (e.target as HTMLInputElement).value });
|
||||
};
|
||||
|
||||
onAddressChange = (e: Event) => {
|
||||
this.setState({ error: null, addressValue: (e.target as HTMLInputElement).value });
|
||||
};
|
||||
|
||||
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: intl.formatMessage(messages.expiredToken) });
|
||||
return;
|
||||
}
|
||||
this.sendForm(value);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
goBack = async () => {
|
||||
// Wait for finding back button in DOM by dropbox
|
||||
// It prevents dropdown from closing, because if dropdown doesn't find clicked element it closes
|
||||
await sleep(0);
|
||||
|
||||
this.setState({
|
||||
tokenValue: '',
|
||||
error: null,
|
||||
verificationSent: false,
|
||||
});
|
||||
|
||||
// Wait for rendering username+email step to find user input
|
||||
await sleep(0);
|
||||
|
||||
if (this.usernameInputRef.current) {
|
||||
this.usernameInputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
getForm1InvalidReason(): string | null {
|
||||
const intl = this.props.intl;
|
||||
if (this.state.loading) return intl.formatMessage(messages.loading);
|
||||
const username = this.state.usernameValue;
|
||||
if (username.length < 3) return intl.formatMessage(loginForm.lengthLimit);
|
||||
if (!validateUserName(username)) return intl.formatMessage(loginForm.symbolLimit);
|
||||
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return intl.formatMessage(messages.invalidEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
getForm2InvalidReason(): string | null {
|
||||
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-email-login-form', {}, { theme: props.theme });
|
||||
if (props.className) {
|
||||
className += ` ${b('auth-email-login-form', {}, { theme: props.theme })}`;
|
||||
}
|
||||
|
||||
const form1InvalidReason = this.getForm1InvalidReason();
|
||||
|
||||
if (!this.state.verificationSent)
|
||||
return (
|
||||
<form className={className} onSubmit={this.onVerificationSubmit}>
|
||||
<Input
|
||||
autofocus
|
||||
name="username"
|
||||
mix="auth-email-login-form__input"
|
||||
ref={this.usernameInputRef}
|
||||
placeholder={intl.formatMessage(loginForm.userName)}
|
||||
value={this.state.usernameValue}
|
||||
onInput={this.onUsernameChange}
|
||||
/>
|
||||
<Input
|
||||
mix="auth-email-login-form__input"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder={intl.formatMessage(messages.emailAddress)}
|
||||
value={this.state.addressValue}
|
||||
onInput={this.onAddressChange}
|
||||
/>
|
||||
{this.state.error && <div className="auth-email-login-form__error">{this.state.error}</div>}
|
||||
<Button
|
||||
mix="auth-email-login-form__submit"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
type="submit"
|
||||
title={form1InvalidReason || ''}
|
||||
disabled={form1InvalidReason !== null}
|
||||
>
|
||||
<FormattedMessage id="emailLoginForm.send-verification" defaultMessage="Send Verification" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const form2InvalidReason = this.getForm2InvalidReason();
|
||||
|
||||
return (
|
||||
<form className={className} onSubmit={this.onSubmit}>
|
||||
<Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
|
||||
<FormattedMessage id="emailLoginForm.back" defaultMessage="Back" />
|
||||
</Button>
|
||||
<TextareaAutosize
|
||||
autofocus={true}
|
||||
name="token"
|
||||
className="auth-email-login-form__token-input"
|
||||
ref={this.tokenRef}
|
||||
placeholder={intl.formatMessage(messages.token)}
|
||||
value={this.state.tokenValue}
|
||||
onInput={this.onTokenChange}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{this.state.error && <div className="auth-email-login-form__error">{this.state.error}</div>}
|
||||
<Button
|
||||
mix="auth-email-login-form__submit"
|
||||
type="submit"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
title={form2InvalidReason || ''}
|
||||
disabled={form2InvalidReason !== null}
|
||||
>
|
||||
<FormattedMessage id="emailLoginForm.confirm" defaultMessage="Confirm" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type EmailLoginFormRef = EmailLoginForm;
|
||||
|
||||
export const EmailLoginFormConnected = forwardRef<EmailLoginForm, OwnProps>((props, ref) => {
|
||||
const intl = useIntl();
|
||||
return <EmailLoginForm {...props} sendEmailVerification={sendEmailVerificationRequest} intl={intl} ref={ref} />;
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import './auth__email-login-form.css';
|
||||
|
||||
export { EmailLoginForm, EmailLoginFormConnected } from './auth__email-login-form';
|
||||
export type { EmailLoginFormRef } from './auth__email-login-form';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { User } from 'common/types';
|
||||
|
||||
import { authFetcher } from 'common/fetcher';
|
||||
import { siteId } from 'common/settings';
|
||||
|
||||
const EMAIL_SIGNIN_ENDPOINT = '/email/login';
|
||||
|
||||
export function anonymousSignin(user: string): Promise<User> {
|
||||
return authFetcher.get<User>('/anonymous/login', { user, aud: siteId });
|
||||
}
|
||||
|
||||
/**
|
||||
* First step of two of `email` authorization
|
||||
*/
|
||||
export function emailSignin(email: string, username: string): Promise<unknown> {
|
||||
return authFetcher.get(EMAIL_SIGNIN_ENDPOINT, { address: email, user: username });
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step of two of `email` authorization
|
||||
*/
|
||||
export function verifyEmailSignin(token: string): Promise<User> {
|
||||
return authFetcher.get(EMAIL_SIGNIN_ENDPOINT, { token });
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return authFetcher.get('/logout');
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export function useDropdown(disableClosing?: boolean) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const toggleDropdownState = () => {
|
||||
setShowDropdown((s) => !s);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdownElement = rootRef.current;
|
||||
|
||||
function handleMessageFromParent(evt: MessageEvent) {
|
||||
if (typeof evt.data !== 'string' || disableClosing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(evt.data);
|
||||
|
||||
if (!data.clickOutside) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDropdown(false);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function handleClickOutside(evt: MouseEvent) {
|
||||
if (disableClosing || dropdownElement?.contains(evt.target as HTMLDivElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDropdown(false);
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
window.addEventListener('message', handleMessageFromParent);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
window.removeEventListener('message', handleMessageFromParent);
|
||||
};
|
||||
}, [showDropdown, disableClosing]);
|
||||
|
||||
useEffect(() => {
|
||||
const dropdownElement = rootRef.current;
|
||||
|
||||
if (!dropdownElement || !showDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevHeight: number | null = null;
|
||||
|
||||
function handleDropdownContentChange() {
|
||||
const { top } = dropdownElement.getBoundingClientRect();
|
||||
const height = window.scrollY + Math.abs(top) + dropdownElement.scrollHeight + 20;
|
||||
|
||||
if (prevHeight === null && window.innerHeight > height) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevHeight = height;
|
||||
document.body.style.setProperty('min-height', `${height}px`);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(handleDropdownContentChange);
|
||||
|
||||
observer.observe(dropdownElement, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
document.body.style.removeProperty('min-height');
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
return [rootRef, showDropdown, toggleDropdownState] as const;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
signin: {
|
||||
id: 'auth.signin',
|
||||
defaultMessage: 'Sign In',
|
||||
},
|
||||
or: {
|
||||
id: 'auth.or',
|
||||
defaultMessage: 'or',
|
||||
},
|
||||
username: {
|
||||
id: 'auth.username',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
usernameRestriction: {
|
||||
id: 'auth.symbols-restriction',
|
||||
defaultMessage: 'Username must contain only letters, numbers, underscores or spaces',
|
||||
},
|
||||
userNotFound: {
|
||||
id: 'auth.user-not-found',
|
||||
defaultMessage: 'No user was found',
|
||||
},
|
||||
emailAddress: {
|
||||
id: 'auth.email-address',
|
||||
defaultMessage: 'Email Address',
|
||||
},
|
||||
token: {
|
||||
id: 'token',
|
||||
defaultMessage: 'Token',
|
||||
},
|
||||
expiredToken: {
|
||||
id: 'token.expired',
|
||||
defaultMessage: 'Token is expired',
|
||||
},
|
||||
invalidToken: {
|
||||
id: 'token.invalid',
|
||||
defaultMessage: 'Token is invalid',
|
||||
},
|
||||
oauthTitle: {
|
||||
id: 'auth.oauth-button',
|
||||
defaultMessage: 'Sign In with {provider}',
|
||||
},
|
||||
back: {
|
||||
id: 'auth.back',
|
||||
defaultMessage: 'Back',
|
||||
},
|
||||
loading: {
|
||||
id: 'auth.loading',
|
||||
defaultMessage: 'Loading...',
|
||||
},
|
||||
submit: {
|
||||
id: 'auth.submit',
|
||||
defaultMessage: 'Submit',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,4 +1,226 @@
|
||||
.auth {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--color34);
|
||||
}
|
||||
|
||||
:global(.dark) .root {
|
||||
color: var(--color16);
|
||||
}
|
||||
|
||||
.buttonArrow {
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
margin-left: 12px;
|
||||
height: 100%;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 100%;
|
||||
border-left: 1px solid rgba(var(--white-color), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 240px;
|
||||
padding: 16px;
|
||||
background-color: rgb(var(--white-color));
|
||||
box-shadow: 0 10px 15px rgba(var(--black-color), 0.1), 0 -1px 6px rgba(var(--black-color), 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown {
|
||||
background-color: var(--color8);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: rgb(rgb(var(--secondary-text-color)));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: calc(100% + 32px);
|
||||
margin: 16px -16px;
|
||||
height: 16px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
content: '';
|
||||
border-top: 1px solid var(--line-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding: 0 12px;
|
||||
content: attr(title);
|
||||
font-size: 12px;
|
||||
color: rgb(var(--secondary-text-color));
|
||||
text-transform: uppercase;
|
||||
background-color: rgb(var(--white-color));
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .divider {
|
||||
&::after {
|
||||
background-color: var(--color8);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.provider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--secondary-text-color));
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider + .provider {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.radio {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .provider {
|
||||
color: var(--color32);
|
||||
}
|
||||
|
||||
.radio:checked + .provider {
|
||||
/* stylelint-disable-next-line mavrin/stylelint-declaration-use-css-custom-properties */
|
||||
color: rgb(var(--primary-color));
|
||||
background-color: rgba(var(--primary-color), 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:global(.dark) .radio:checked + .provider {
|
||||
color: rgb(var(--white-color));
|
||||
background-color: rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.backButtonArrow {
|
||||
display: inline-block;
|
||||
margin-left: -4px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: unset;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--primary-color), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
composes: input from 'components/input/input.module.css';
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
resize: vertical;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.honeypot {
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(var(--white-color), 0.2);
|
||||
border-right-color: rgb(var(--white-color));
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--error-background);
|
||||
color: var(--error-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import enMessages from 'locales/en.json';
|
||||
import stubStore from '__stubs__/store';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
|
||||
import Auth from './auth';
|
||||
import { mount } from 'enzyme';
|
||||
import { Button } from '../button';
|
||||
import { StoreState } from 'store';
|
||||
|
||||
const initialStore = {
|
||||
provider: { name: 'google' },
|
||||
theme: 'light',
|
||||
comments: {
|
||||
sort: '-score',
|
||||
} as StoreState['comments'],
|
||||
} as const;
|
||||
|
||||
describe('<Auth/>', () => {
|
||||
const createWrapper = (store?: Partial<StoreState>) =>
|
||||
mount(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<Provider store={stubStore(store || initialStore)}>
|
||||
<Auth />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
it('should render login form with google and github provider', () => {
|
||||
StaticStore.config.auth_providers = ['google', 'github'];
|
||||
const element = createWrapper();
|
||||
|
||||
const providersButtons = element.find(Button);
|
||||
|
||||
expect(element.text()).toEqual(expect.stringContaining('Login:'));
|
||||
|
||||
expect(providersButtons.at(0).text()).toEqual('Google');
|
||||
expect(providersButtons.at(1).text()).toEqual('GitHub');
|
||||
});
|
||||
|
||||
describe('providers sorting', () => {
|
||||
it('should do nothing if provider not found', () => {
|
||||
StaticStore.config.auth_providers = ['google', 'github'];
|
||||
const element = createWrapper({
|
||||
...initialStore,
|
||||
provider: { name: 'baidu' },
|
||||
});
|
||||
|
||||
const providerLinks = element.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('Google');
|
||||
expect(providerLinks.at(1).text()).toEqual('GitHub');
|
||||
});
|
||||
it('should place selected provider first', () => {
|
||||
StaticStore.config.auth_providers = ['google', 'github'];
|
||||
const element = createWrapper({
|
||||
...initialStore,
|
||||
provider: { name: 'github' },
|
||||
});
|
||||
|
||||
const providerLinks = element.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('GitHub');
|
||||
expect(providerLinks.at(1).text()).toEqual('Google');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +1,277 @@
|
||||
import { h, Component, createRef } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { IntlShape, FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { h, Fragment, FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useIntl } from 'react-intl';
|
||||
import cn from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { AuthProvider, Theme, User } from 'common/types';
|
||||
import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE } from 'common/constants';
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
import { Button } from 'components/button';
|
||||
import { Dropdown, DropdownItem } from 'components/dropdown';
|
||||
import { setUser } from 'store/user/actions';
|
||||
import { Input } from 'components/input';
|
||||
import TextareaAutosize from 'components/textarea-autosize';
|
||||
|
||||
import debounce from 'utils/debounce';
|
||||
import { ProviderState } from 'store/provider/reducers';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { StoreState } from 'store';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import { logIn } from 'store/user/actions';
|
||||
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
import { EmailLoginFormConnected, EmailLoginFormRef } from './__email-login-form';
|
||||
import Button from './components/button';
|
||||
import OAuthProviders from './components/oauth';
|
||||
import messages from './auth.messsages';
|
||||
import { useDropdown } from './auth.hooks';
|
||||
import { getProviders, getTokenInvalidReason } from './auth.utils';
|
||||
import { emailSignin, verifyEmailSignin, anonymousSignin } from './auth.api';
|
||||
|
||||
import styles from './auth.module.css';
|
||||
|
||||
interface Props {
|
||||
intl: IntlShape;
|
||||
theme: Theme;
|
||||
onSignIn(provider: AuthProvider): any; // eslint-disable-line
|
||||
user: User | null;
|
||||
provider: ProviderState;
|
||||
}
|
||||
|
||||
interface State {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
class Auth extends Component<Props, State> {
|
||||
emailLoginRef = createRef<EmailLoginFormRef>();
|
||||
singInMessageAndSortWidth = 255;
|
||||
|
||||
state = {
|
||||
threshold: 3,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.resizeHandler();
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
resizeHandler = debounce(() => {
|
||||
this.setState({
|
||||
threshold: Math.max(3, Math.round((window.innerWidth - this.singInMessageAndSortWidth) / 80)),
|
||||
});
|
||||
}, 100);
|
||||
|
||||
onEmailTitleClick = () => {
|
||||
this.emailLoginRef.current && this.emailLoginRef.current.focus();
|
||||
};
|
||||
|
||||
onEmailSignIn = (token: string) => {
|
||||
return this.props.onSignIn({ name: 'email', token });
|
||||
};
|
||||
|
||||
handleOAuthLogin = async (e: MouseEvent | KeyboardEvent) => {
|
||||
const name = (e.target as HTMLButtonElement).dataset.provider! as AuthProvider['name'];
|
||||
|
||||
this.props.onSignIn({ name } as AuthProvider);
|
||||
};
|
||||
|
||||
handleAnonymousLoginFormSubmut = async (username: string) => {
|
||||
this.props.onSignIn({ name: 'anonymous', username });
|
||||
};
|
||||
|
||||
renderOther = (providers: AuthProvider['name'][]) => {
|
||||
const other = this.props.intl.formatMessage(authPanelMessages.otherProvider);
|
||||
|
||||
return (
|
||||
<Dropdown title={other} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
|
||||
{providers.map((provider) => (
|
||||
<DropdownItem>{this.renderProvider(provider)}</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
renderProvider = (provider: AuthProvider['name']) => {
|
||||
if (provider === 'anonymous') {
|
||||
const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider);
|
||||
return (
|
||||
<Dropdown title={anonymous} theme={this.props.theme}>
|
||||
<DropdownItem>
|
||||
<AnonymousLoginForm
|
||||
onSubmit={this.handleAnonymousLoginFormSubmut}
|
||||
theme={this.props.theme}
|
||||
intl={this.props.intl}
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
if (provider === 'email') {
|
||||
return (
|
||||
<Dropdown title={PROVIDER_NAMES['email']} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
|
||||
<DropdownItem>
|
||||
<EmailLoginFormConnected ref={this.emailLoginRef} onSignIn={this.onEmailSignIn} theme={this.props.theme} />
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button kind="link" data-provider={provider} {...getHandleClickProps(this.handleOAuthLogin)} role="link">
|
||||
{PROVIDER_NAMES[provider]}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
render({ intl }: Props, { threshold }: State) {
|
||||
if (!IS_STORAGE_AVAILABLE) return null;
|
||||
|
||||
const sortedProviders = ((providers): typeof providers => {
|
||||
if (!this.props.provider.name) return providers;
|
||||
const lastProviderIndex = providers.indexOf(this.props.provider.name as typeof providers[0]);
|
||||
if (lastProviderIndex < 1) return providers;
|
||||
return [
|
||||
this.props.provider.name as typeof providers[0],
|
||||
...providers.slice(0, lastProviderIndex),
|
||||
...providers.slice(lastProviderIndex + 1),
|
||||
];
|
||||
})(StaticStore.config.auth_providers);
|
||||
|
||||
const isAboveThreshold = sortedProviders.length > threshold;
|
||||
const or = intl.formatMessage(authPanelMessages.orProvider);
|
||||
|
||||
return (
|
||||
<div className={styles.auth}>
|
||||
<FormattedMessage id="authPanel.login" defaultMessage="Login:" />{' '}
|
||||
{!isAboveThreshold &&
|
||||
sortedProviders.map((provider, i) => {
|
||||
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', ';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{comma}
|
||||
{this.renderProvider(provider)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{isAboveThreshold &&
|
||||
sortedProviders.slice(0, threshold - 1).map((provider, i) => {
|
||||
const comma = i === 0 ? '' : ', ';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{comma}
|
||||
{this.renderProvider(provider)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{isAboveThreshold && (
|
||||
<span>
|
||||
{` ${or} `}
|
||||
{this.renderOther(sortedProviders.slice(threshold - 1))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 default function AuthWrapper() {
|
||||
const dispatch = useDispatch();
|
||||
const provider = useSelector<StoreState, ProviderState>((store) => store.provider);
|
||||
const user = useSelector<StoreState, User | null>((store) => store.user);
|
||||
const theme = useTheme();
|
||||
const Auth: FunctionComponent = () => {
|
||||
const intl = useIntl();
|
||||
const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), [dispatch]);
|
||||
const dispath = useDispatch();
|
||||
const [oauthProviders, formProviders] = getProviders();
|
||||
|
||||
return <Auth provider={provider} theme={theme} onSignIn={handleSignin} intl={intl} user={user} />;
|
||||
}
|
||||
// UI State
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [view, setView] = useState<typeof formProviders[number] | 'token'>(formProviders[0]);
|
||||
const [ref, isDropdownShowed, toggleDropdownState] = useDropdown(view === 'token');
|
||||
|
||||
// Errors
|
||||
const [invalidReason, setInvalidReason] = useState<keyof typeof messages | null>(null);
|
||||
|
||||
const handleClickSingIn = (evt: Event) => {
|
||||
evt.preventDefault();
|
||||
toggleDropdownState();
|
||||
};
|
||||
|
||||
const handleDropdownClose = (evt: Event) => {
|
||||
evt.preventDefault();
|
||||
setView(formProviders[0]);
|
||||
toggleDropdownState();
|
||||
};
|
||||
|
||||
const handleProviderChange = (evt: Event) => {
|
||||
const { value } = evt.currentTarget as HTMLInputElement;
|
||||
|
||||
setInvalidReason(null);
|
||||
setView(value as typeof formProviders[number]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (evt: Event) => {
|
||||
const data = new FormData(evt.target as HTMLFormElement);
|
||||
|
||||
evt.preventDefault();
|
||||
setLoading(true);
|
||||
setInvalidReason(null);
|
||||
|
||||
try {
|
||||
switch (view) {
|
||||
case 'anonymous': {
|
||||
const username = data.get('username') as string;
|
||||
const user = await anonymousSignin(username);
|
||||
|
||||
dispath(setUser(user));
|
||||
break;
|
||||
}
|
||||
case 'email': {
|
||||
const email = data.get('email') as string;
|
||||
const username = data.get('username') as string;
|
||||
|
||||
await emailSignin(email, username);
|
||||
setView('token');
|
||||
break;
|
||||
}
|
||||
case 'token': {
|
||||
const token = data.get('token') as string;
|
||||
const invalidReason = getTokenInvalidReason(token);
|
||||
|
||||
if (invalidReason) {
|
||||
setInvalidReason(invalidReason);
|
||||
} else {
|
||||
const user = await verifyEmailSignin(token);
|
||||
dispath(setUser(user));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setInvalidReason(e.message || e.error);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleShowEmailStep = (evt: Event) => {
|
||||
evt.preventDefault();
|
||||
setView('email');
|
||||
};
|
||||
|
||||
const hasOAuthProviders = oauthProviders.length > 0;
|
||||
const hasFormProviders = formProviders.length > 0;
|
||||
const errorMessage =
|
||||
invalidReason !== null && invalidReason in messages ? intl.formatMessage(messages[invalidReason]) : invalidReason;
|
||||
const isTokenView = view === 'token';
|
||||
const submitButton = (
|
||||
<Button className="auth-submit" type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn('spinner', styles.spinner)}
|
||||
role="presentation"
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
) : (
|
||||
intl.formatMessage(isTokenView ? messages.signin : messages.submit)
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('auth', styles.root)}>
|
||||
<Button
|
||||
className="auth-button"
|
||||
selected={isDropdownShowed}
|
||||
onClick={handleClickSingIn}
|
||||
suffix={
|
||||
<svg width="14" height="14" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M6 11.5L14.5 19L22 11"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
{isDropdownShowed && (
|
||||
<div className={cn('auth-dropdown', styles.dropdown)} ref={ref}>
|
||||
<form className={cn('auth-form', styles.form)} onSubmit={handleSubmit}>
|
||||
{isTokenView ? (
|
||||
<>
|
||||
<div className={cn('auth-row', styles.row)}>
|
||||
<div className={styles.backButton}>
|
||||
<Button className="auth-back-button" size="small" kind="transparent" onClick={handleShowEmailStep}>
|
||||
<svg
|
||||
className={styles.backButtonArrow}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.75 3L5 7.25L9 11"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.back)}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className={cn('auth-close-button', styles.closeButton)}
|
||||
title="Close sign-in dropdown"
|
||||
onClick={handleDropdownClose}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 2L12 12M12 2L2 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={cn('auth-row', styles.row)}>
|
||||
<TextareaAutosize
|
||||
name="token"
|
||||
className={cn('auth-token-textatea', styles.textarea)}
|
||||
placeholder={intl.formatMessage(messages.token)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button className="auth-submit" type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn('spinner', styles.spinner)}
|
||||
role="presentation"
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
) : (
|
||||
intl.formatMessage(messages.submit)
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasOAuthProviders && (
|
||||
<>
|
||||
<h5 className={cn('auth-form-title', styles.title)}>Use Social Network</h5>
|
||||
<OAuthProviders providers={oauthProviders} />
|
||||
</>
|
||||
)}
|
||||
{hasOAuthProviders && hasFormProviders && (
|
||||
<div className={cn('auth-divider', styles.divider)} title={intl.formatMessage(messages.or)} />
|
||||
)}
|
||||
{hasFormProviders && (
|
||||
<>
|
||||
{formProviders.length === 1 ? (
|
||||
<h5 className={cn('auth-form-title', styles.title)}>{formProviders[0]}</h5>
|
||||
) : (
|
||||
<div className={cn('auth-tabs', styles.tabs)}>
|
||||
{formProviders.map((p) => (
|
||||
<Fragment key={p}>
|
||||
<input
|
||||
className={styles.radio}
|
||||
type="radio"
|
||||
id={`form-provider-${p}`}
|
||||
name="form-provider"
|
||||
value={p}
|
||||
onChange={handleProviderChange}
|
||||
checked={p === view}
|
||||
/>
|
||||
<label className={cn('auth-tabs-item', styles.provider)} htmlFor={`form-provider-${p}`}>
|
||||
{p.slice(0, 6)}
|
||||
</label>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('auth-row', styles.row)}>
|
||||
<Input
|
||||
className="auth-input-username"
|
||||
required
|
||||
name="username"
|
||||
minLength={3}
|
||||
pattern="^[\p{L}\d_]+$"
|
||||
title={intl.formatMessage(messages.usernameRestriction)}
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{view === 'email' && (
|
||||
<div className={cn('auth-row', styles.row)}>
|
||||
<Input
|
||||
className="auth-input-email"
|
||||
required
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={intl.formatMessage(messages.emailAddress)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input className={styles.honeypot} type="checkbox" tabIndex={-1} autoComplete="off" />
|
||||
{errorMessage && <div className={cn('auth-error', styles.error)}>{errorMessage}</div>}
|
||||
{submitButton}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auth;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isJwtExpired } from 'utils/jwt';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
import type { FormProvider, OAuthProvider } from 'common/types';
|
||||
|
||||
import { OAUTH_PROVIDERS } from './components/oauth.consts';
|
||||
import messages from './auth.messsages';
|
||||
|
||||
export function getProviders(): [OAuthProvider[], FormProvider[]] {
|
||||
const oauthProviders: OAuthProvider[] = [];
|
||||
const formProviders: FormProvider[] = [];
|
||||
|
||||
StaticStore.config.auth_providers.forEach((p) => {
|
||||
OAUTH_PROVIDERS.includes(p) ? oauthProviders.push(p as OAuthProvider) : formProviders.push(p as FormProvider);
|
||||
});
|
||||
|
||||
return [oauthProviders, formProviders];
|
||||
}
|
||||
|
||||
export function getTokenInvalidReason(token: string): null | keyof typeof messages {
|
||||
try {
|
||||
if (isJwtExpired(token)) {
|
||||
return 'expiredToken';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'invalidToken';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.79075 2.88032C6.78831 2.88182 6.78551 2.88366 6.78234 2.88588C5.32505 3.90425 4.375 5.59144 4.375 7.50039C4.375 9.49759 5.41519 11.2525 6.9878 12.2519C7.63477 12.663 8.14823 13.406 8.11451 14.3026L8.11411 14.3131L7.9102 18.4836C7.88492 19.0007 7.4452 19.3995 6.92805 19.3741C6.4109 19.3489 6.01216 18.9091 6.03744 18.392L6.24094 14.2289C6.24345 14.1236 6.17916 13.9596 5.98214 13.8344C3.89097 12.5055 2.5 10.1657 2.5 7.50039C2.5 4.95327 3.7706 2.70306 5.70832 1.34896C6.39479 0.869256 7.19942 0.915448 7.79969 1.29986C8.37699 1.66959 8.75 2.33371 8.75 3.0789V6.60271C8.75 6.70624 8.80129 6.80306 8.88694 6.86122L9.82444 7.4979C9.93041 7.56987 10.0696 7.56987 10.1756 7.4979L11.1131 6.86122C11.1987 6.80306 11.25 6.70625 11.25 6.60271V3.0789C11.25 2.33371 11.623 1.66959 12.2003 1.29986C12.8006 0.915448 13.6053 0.869256 14.2916 1.34896C16.2294 2.70306 17.5 4.95327 17.5 7.50039C17.5 10.1657 16.109 12.5055 14.0179 13.8344C13.8209 13.9596 13.7565 14.1236 13.7591 14.2289L13.9625 18.392C13.9879 18.9091 13.5891 19.3489 13.072 19.3741C12.5547 19.3995 12.1151 19.0007 12.0898 18.4836L11.8854 14.3026C11.8517 13.406 12.3652 12.663 13.0122 12.2519C14.5849 11.2525 15.625 9.49759 15.625 7.50039C15.625 5.59144 14.675 3.90425 13.2176 2.88588C13.2145 2.88366 13.2117 2.88182 13.2092 2.88032C13.1792 2.90092 13.125 2.96434 13.125 3.0789V6.60271C13.125 7.32746 12.766 8.00517 12.1665 8.41235L11.229 9.04902C10.4871 9.55284 9.51289 9.55284 8.77104 9.04902L7.83354 8.41235C7.23396 8.00517 6.875 7.32746 6.875 6.60271V3.0789C6.875 2.96434 6.8208 2.90092 6.79075 2.88032ZM6.80349 2.87372C6.80413 2.87355 6.80349 2.87375 6.80349 2.87372V2.87372Z" fill="#1BB8AE"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 10.0609C20 4.50304 15.5242 0 10 0C4.47581 0 0 4.50304 0 10.0609C0 15.0824 3.65686 19.2446 8.4375 20V12.9692H5.89718V10.0609H8.4375V7.84422C8.4375 5.32292 9.92944 3.93022 12.2145 3.93022C13.3089 3.93022 14.4532 4.12657 14.4532 4.12657V6.60122H13.1919C11.95 6.60122 11.5625 7.37688 11.5625 8.17241V10.0609H14.3359L13.8923 12.9692H11.5625V20C16.3431 19.2446 20 15.0824 20 10.0609Z" fill="#0A82ED"/></svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.7 16.1l-.2.2c-.2 0-.2-.1-.2-.2l.2-.1.2.1zm-1.3-.2c0 .1 0 .2.2.2H6c0-.1 0-.2-.2-.3l-.3.1zm1.8 0c-.1 0-.2 0-.2.2h.3l.2-.1-.3-.2zM10 0A9.8 9.8 0 000 10c0 4.7 2.8 8.6 6.8 10 .6 0 .7-.2.7-.5v-2.6s-2.8.7-3.4-1.2c0 0-.5-1.2-1.1-1.5 0 0-1-.7 0-.6 0 0 1 0 1.6 1 .9 1.6 2.4 1.2 3 .9 0-.7.3-1.1.6-1.4-2.3-.3-4.5-.6-4.5-4.6 0-1.1.3-1.7 1-2.4a4 4 0 010-2.8c.9-.3 2.8 1.1 2.8 1.1a9.3 9.3 0 015 0s2-1.4 2.8-1.1a4 4 0 01.2 2.8c.6.7 1 1.3 1 2.4 0 4-2.4 4.3-4.6 4.6.3.3.6 1 .6 2v3.4c0 .2.2.6.7.5 4-1.4 6.8-5.3 6.8-10A10 10 0 009.9 0zm-6 14.3v.2h.3v-.2h-.3zm-.4-.4v.2h.3l-.1-.2h-.2zm1.3 1.5v.3h.3v-.3h-.3zm-.5-.6v.2c.1.1.2.2.3.1V15c-.1-.1-.2-.2-.3-.1z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.7 16.1l-.2.2c-.2 0-.2-.1-.2-.2l.2-.1.2.1zm-1.3-.2c0 .1 0 .2.2.2H6c0-.1 0-.2-.2-.3l-.3.1zm1.8 0c-.1 0-.2 0-.2.2h.3l.2-.1-.3-.2zM10 0A9.8 9.8 0 000 10c0 4.7 2.8 8.6 6.8 10 .6 0 .7-.2.7-.5v-2.6s-2.8.7-3.4-1.2c0 0-.5-1.2-1.1-1.5 0 0-1-.7 0-.6 0 0 1 0 1.6 1 .9 1.6 2.4 1.2 3 .9 0-.7.3-1.1.6-1.4-2.3-.3-4.5-.6-4.5-4.6 0-1.1.3-1.7 1-2.4a4 4 0 010-2.8c.9-.3 2.8 1.1 2.8 1.1a9.3 9.3 0 015 0s2-1.4 2.8-1.1a4 4 0 01.2 2.8c.6.7 1 1.3 1 2.4 0 4-2.4 4.3-4.6 4.6.3.3.6 1 .6 2v3.4c0 .2.2.6.7.5 4-1.4 6.8-5.3 6.8-10A10 10 0 009.9 0zm-6 14.3v.2h.3v-.2h-.3zm-.4-.4v.2h.3l-.1-.2h-.2zm1.3 1.5v.3h.3v-.3h-.3zm-.5-.6v.2c.1.1.2.2.3.1V15c-.1-.1-.2-.2-.3-.1z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 10.2c0-.6 0-1.2-.2-1.8h-8.6v3.4h5a4.2 4.2 0 01-1.9 2.8v2.2h3a8.7 8.7 0 002.7-6.6z" fill="#4285F4"/><path d="M10.2 19c2.5 0 4.5-.8 6-2.2l-2.9-2.2c-.8.5-1.9.8-3.1.8A5.5 5.5 0 015 11.7H2V14c1.5 3 4.7 5 8.2 5z" fill="#34A853"/><path d="M5 11.7a5.3 5.3 0 010-3.4V6H2a8.8 8.8 0 000 8l3-2.3z" fill="#FBBC04"/><path d="M10.2 4.6a5 5 0 013.5 1.3l2.6-2.6A9.2 9.2 0 002 6l3 2.4a5.5 5.5 0 015.2-3.7z" fill="#EA4335"/></svg>
|
||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="#EC4C23" d="M0 0h9v9H0z"/><path fill="#7DB300" d="M11 0h9v9h-9z"/><path fill="#F7B302" d="M11 11h9v9h-9z"/><path fill="#019FE8" d="M0 11h9v9H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.94 5.54c.02.2.02.4.02.58 0 5.94-4.13 12.77-11.67 12.77-2.32 0-4.48-.74-6.29-2.02a7.77 7.77 0 006.08-1.86 4.17 4.17 0 01-3.83-3.1 3.99 3.99 0 001.85-.08A4.4 4.4 0 01.8 7.4v-.06c.55.33 1.18.54 1.85.57a4.78 4.78 0 01-1.27-6 11.35 11.35 0 008.46 4.7c-.07-.34-.1-.69-.1-1.03 0-2.48 1.82-4.49 4.1-4.49 1.18 0 2.24.54 2.99 1.42.93-.2 1.81-.57 2.6-1.09a4.42 4.42 0 01-1.8 2.48c.82-.1 1.62-.35 2.36-.7a9.31 9.31 0 01-2.06 2.32z" fill="#1DA1F2"/></svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.36 0h-1.73c-.15 0-.25.07-.27.18-.02.1-2.59 7.78-2.78 8.42l-.66 2.44-.9-2.4C8.81 8 6.72 2.3 6.67 2.08c-.04-.13-.1-.29-.32-.29H4.66c-.16 0-.25.18-.2.3.04.08 3.09 8.02 4.32 11.05v6.68c0 .12.05.19.17.19h1.6c.1 0 .17-.07.17-.2V13.2c1.03-2.81 4.77-12.8 4.81-12.9.05-.15.04-.29-.17-.29z" fill="red"/></svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,84 @@
|
||||
.button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid;
|
||||
border-color: transparent;
|
||||
padding: 7px 12px;
|
||||
margin: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background-color: rgb(var(--primary-color));
|
||||
color: rgb(var(--white-color));
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(var(--primary-brighter-color));
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.4);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
background-color: var(--color9);
|
||||
}
|
||||
}
|
||||
|
||||
.suffix {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: -12px;
|
||||
margin-left: 12px;
|
||||
padding: 0 10px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
height: 36px;
|
||||
border-left: 1px solid rgba(var(--white-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
background-color: rgb(var(--primary-color));
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.transparent {
|
||||
background-color: rgba(var(--primary-color), 0.1);
|
||||
color: rgb(var(--primary-color));
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--primary-color), 0.2);
|
||||
color: rgb(var(--primary-color));
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
& .button {
|
||||
border-color: rgba(var(--white-color), 0.1);
|
||||
}
|
||||
|
||||
& .transparent {
|
||||
border-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { h, FunctionComponent, JSX, VNode } from 'preact';
|
||||
import classnames from 'classnames/bind';
|
||||
|
||||
import styles from './button.module.css';
|
||||
|
||||
const cx = classnames.bind(styles);
|
||||
|
||||
export type ButtonProps = Omit<JSX.HTMLAttributes<HTMLButtonElement>, 'size'> & {
|
||||
size?: 'small';
|
||||
kind?: 'transparent';
|
||||
suffix?: VNode;
|
||||
loading?: boolean;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
const Button: FunctionComponent<ButtonProps> = ({ children, size, kind, suffix, selected, className, ...props }) => {
|
||||
return (
|
||||
<button className={cx(className, styles.button, kind, size, { selected })} {...props}>
|
||||
{children}
|
||||
{suffix && <div className={styles.suffix}>{suffix}</div>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getUser } from 'common/api';
|
||||
import { User } from 'common/types';
|
||||
|
||||
/**
|
||||
* Performs await of auth from oauth providers
|
||||
*/
|
||||
let subscribed = false;
|
||||
let timeout: NodeJS.Timeout;
|
||||
let authWindow: Window | null = null;
|
||||
|
||||
/**
|
||||
* Set waiting state and tries to revalidate `user` when oauth tab is closed
|
||||
*/
|
||||
export function oauthSignin(url: string): Promise<User | null> {
|
||||
authWindow = window.open(url);
|
||||
|
||||
if (subscribed) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
function unsubscribe() {
|
||||
document.removeEventListener('visibilitychange', handleWindowVisibilityChange);
|
||||
window.removeEventListener('focus', handleWindowVisibilityChange);
|
||||
subscribed = false;
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
async function handleWindowVisibilityChange() {
|
||||
if (!document.hasFocus() || document.hidden || !authWindow?.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getUser();
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (user === null) {
|
||||
// Retry after 1 min if current attempt unsuccessful
|
||||
timeout = setTimeout(() => {
|
||||
handleWindowVisibilityChange();
|
||||
}, 60 * 1000);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resolve(user);
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
reject();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
document.addEventListener('visibilitychange', handleWindowVisibilityChange);
|
||||
window.addEventListener('focus', handleWindowVisibilityChange);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const OAUTH_DATA = {
|
||||
facebook: require('./assets/facebook.svg').default as string,
|
||||
twitter: require('./assets/twitter.svg').default as string,
|
||||
google: require('./assets/google.svg').default as string,
|
||||
microsoft: require('./assets/microsoft.svg').default as string,
|
||||
yandex: require('./assets/yandex.svg').default as string,
|
||||
dev: require('./assets/dev.svg').default as string,
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icons: {
|
||||
light: require('./assets/github-light.svg').default as string,
|
||||
dark: require('./assets/github-dark.svg').default as string,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const OAUTH_PROVIDERS = Object.keys(OAUTH_DATA);
|
||||
@@ -0,0 +1,71 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line-color);
|
||||
border-radius: 50%;
|
||||
|
||||
&::after {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
border-color: var(--line-brighter-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--line-brighter-color);
|
||||
box-shadow: 0 0 0 2px var(--line-color);
|
||||
}
|
||||
}
|
||||
|
||||
.name,
|
||||
.full {
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
content: attr(data-provider-name);
|
||||
}
|
||||
}
|
||||
|
||||
.full::after {
|
||||
content: attr(title);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { h } from 'preact';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/preact';
|
||||
|
||||
import * as userActions from 'store/user/actions';
|
||||
|
||||
import OAuth from './oauth';
|
||||
import * as api from './oauth.api';
|
||||
import { User } from 'common/types';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useTheme', () => () => 'light');
|
||||
|
||||
describe('<OAuth />', () => {
|
||||
it('should have permanent class name', () => {
|
||||
const { container } = render(<OAuth providers={['google']} />);
|
||||
|
||||
expect(container.querySelector('ul')?.getAttribute('class')).toContain('oauth');
|
||||
expect(container.querySelector('li')?.getAttribute('class')).toContain('oauth-item');
|
||||
expect(container.querySelector('a')?.getAttribute('class')).toContain('oauth-button');
|
||||
expect(container.querySelector('img')?.getAttribute('class')).toContain('oauth-icon');
|
||||
});
|
||||
|
||||
it('should have rigth `href`', () => {
|
||||
const { container } = render(<OAuth providers={['google']} />);
|
||||
|
||||
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||
'/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set user if unauthorized', async () => {
|
||||
const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn());
|
||||
const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => null);
|
||||
const { container } = render(<OAuth providers={['google']} />);
|
||||
|
||||
fireEvent.click(container.querySelector('a')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(oauthSignin).toBeCalledWith(
|
||||
'http://localhost/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark'
|
||||
)
|
||||
);
|
||||
expect(setUser).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should set user if authorized', async () => {
|
||||
const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn());
|
||||
const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => ({} as User));
|
||||
const { container } = render(<OAuth providers={['google']} />);
|
||||
|
||||
fireEvent.click(container.querySelector('a')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(oauthSignin).toBeCalledWith(
|
||||
'http://localhost/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark'
|
||||
)
|
||||
);
|
||||
expect(setUser).toBeCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { h, FunctionComponent, JSX } from 'preact';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { siteId } from 'common/settings';
|
||||
import type { OAuthProvider } from 'common/types';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import { setUser } from 'store/user/actions';
|
||||
|
||||
import messages from 'components/auth/auth.messsages';
|
||||
|
||||
import { getButtonVariant, getProviderData } from './oauth.utils';
|
||||
import { oauthSignin } from './oauth.api';
|
||||
import styles from './oauth.module.css';
|
||||
|
||||
export type OAuthProvidersProps = {
|
||||
providers: OAuthProvider[];
|
||||
};
|
||||
|
||||
const location = encodeURIComponent(`${window.location.origin}${window.location.pathname}?selfClose`);
|
||||
|
||||
const OAuthProviders: FunctionComponent<OAuthProvidersProps> = ({ providers }) => {
|
||||
const intl = useIntl();
|
||||
const dispath = useDispatch();
|
||||
const theme = useTheme();
|
||||
const buttonVariant = getButtonVariant(providers.length);
|
||||
const handleOathClick: JSX.GenericEventHandler<HTMLAnchorElement> = async (evt) => {
|
||||
const { href } = evt.currentTarget as HTMLAnchorElement;
|
||||
|
||||
evt.preventDefault();
|
||||
const user = await oauthSignin(href);
|
||||
|
||||
if (user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispath(setUser(user));
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={cn('oauth', styles.root)}>
|
||||
{providers.map((p) => {
|
||||
const { name, icon } = getProviderData(p, theme);
|
||||
|
||||
return (
|
||||
<li className={cn('oauth-item', styles.item)}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/auth/${p}/login?from=${location}&site=${siteId}`}
|
||||
onClick={handleOathClick}
|
||||
className={cn('oauth-button', styles.button, styles[buttonVariant], styles[p])}
|
||||
data-provider-name={name}
|
||||
title={intl.formatMessage(messages.oauthTitle, { provider: name })}
|
||||
>
|
||||
<img className="oauth-icon" src={icon} width="20" height="20" alt="" aria-hidden={true} />
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthProviders;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { OAuthProvider, Theme } from 'common/types';
|
||||
import capitalizeFirstLetter from 'utils/capitalize-first-letter';
|
||||
|
||||
import { OAUTH_DATA } from './oauth.consts';
|
||||
|
||||
export function getButtonVariant(num: number) {
|
||||
if (num === 2) {
|
||||
return 'name';
|
||||
}
|
||||
|
||||
if (num === 1) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
return 'icon';
|
||||
}
|
||||
|
||||
export function getProviderData(provider: OAuthProvider, theme: Theme) {
|
||||
const data = OAUTH_DATA[provider];
|
||||
|
||||
if (typeof data !== 'string') {
|
||||
return {
|
||||
name: data.name,
|
||||
icon: data.icons[theme],
|
||||
};
|
||||
}
|
||||
|
||||
return { name: capitalizeFirstLetter(provider), icon: data };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { validateUserName } from './validateUserName';
|
||||
|
||||
describe('validate user name', () => {
|
||||
it('should allow good name', () => {
|
||||
expect(validateUserName('Раз_Два Три_34567')).toEqual(true);
|
||||
});
|
||||
it('should not allow bad name', () => {
|
||||
expect(validateUserName('**blah123')).toEqual(false);
|
||||
});
|
||||
it('should not allow only spaces', () => {
|
||||
expect(validateUserName(' ')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
const userNameRegex = /^[\p{L}\d_ ]+$/u;
|
||||
export function validateUserName(userName: string) {
|
||||
return userNameRegex.test(userName.trim());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.button_size_large {
|
||||
height: 2rem;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--color9);
|
||||
color: var(--color17);
|
||||
color: var(--color15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, FunctionComponent, Fragment } from 'preact';
|
||||
import { useState, useCallback, useEffect, useRef, PropRef } from 'preact/hooks';
|
||||
import { useState, useCallback, useRef } from 'preact/hooks';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import b from 'bem-react-helper';
|
||||
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
|
||||
@@ -21,7 +21,7 @@ import TextareaAutosize from 'components/textarea-autosize';
|
||||
import { isUserAnonymous } from 'utils/isUserAnonymous';
|
||||
import { isJwtExpired } from 'utils/jwt';
|
||||
|
||||
const emailRegex = /[^@]+@[^.]+\..+/;
|
||||
const emailRegexp = /[^@]+@[^.]+\..+/;
|
||||
|
||||
enum Step {
|
||||
Email,
|
||||
@@ -33,6 +33,14 @@ enum Step {
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
token: {
|
||||
id: 'token',
|
||||
defaultMessage: 'Token',
|
||||
},
|
||||
expiredToken: {
|
||||
id: 'token.expired',
|
||||
defaultMessage: 'Token is expired',
|
||||
},
|
||||
haveSubscribed: {
|
||||
id: 'subscribeByEmail.have-been-subscribed',
|
||||
defaultMessage: 'You have been subscribed on updates by email',
|
||||
@@ -57,14 +65,6 @@ const messages = defineMessages({
|
||||
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',
|
||||
@@ -75,16 +75,15 @@ const renderEmailPart = (
|
||||
loading: boolean,
|
||||
intl: IntlShape,
|
||||
emailAddress: string,
|
||||
handleChangeEmail: (e: Event) => void,
|
||||
emailAddressRef: PropRef<HTMLInputElement>
|
||||
handleChangeEmail: (e: Event) => void
|
||||
) => (
|
||||
<>
|
||||
<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"
|
||||
autofocus
|
||||
className="comment-form__subscribe-by-email__input"
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
value={emailAddress}
|
||||
onInput={handleChangeEmail}
|
||||
@@ -122,7 +121,6 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
const subscribed = useSelector<StoreState, boolean>(({ user }) =>
|
||||
user === null ? false : Boolean(user.email_subscription)
|
||||
);
|
||||
const emailAddressRef = useRef<HTMLInputElement>();
|
||||
const previousStep = useRef<Step | null>(null);
|
||||
|
||||
const [step, setStep] = useState(subscribed ? Step.Subscribed : Step.Email);
|
||||
@@ -199,7 +197,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
[sendForm]
|
||||
);
|
||||
|
||||
const isValidEmailAddress = emailRegex.test(emailAddress);
|
||||
const isValidEmailAddress = emailRegexp.test(emailAddress);
|
||||
|
||||
const setEmailStep = useCallback(async () => {
|
||||
await sleep(0);
|
||||
@@ -221,12 +219,6 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
}
|
||||
}, [setLoading, setStep, setError, dispatch, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailAddressRef.current) {
|
||||
emailAddressRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* It needs for dropdown closing by click on button
|
||||
* More info below
|
||||
@@ -288,7 +280,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
|
||||
return (
|
||||
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
|
||||
{step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail, emailAddressRef)}
|
||||
{step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail)}
|
||||
{step === Step.Token && renderTokenPart(loading, intl, token, handleChangeToken, setEmailStep)}
|
||||
{error !== null && (
|
||||
<div className="comment-form__subscribe-by-email__error" role="alert">
|
||||
|
||||
@@ -7,17 +7,6 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn((fn) => fn({ theme: 'light' })),
|
||||
}));
|
||||
|
||||
jest.mock('react-intl', () => {
|
||||
const reactIntl = jest.requireActual('react-intl');
|
||||
const messages = require('locales/en.json');
|
||||
const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {});
|
||||
|
||||
return {
|
||||
...reactIntl,
|
||||
useIntl: () => intlProvider.state.intl,
|
||||
};
|
||||
});
|
||||
|
||||
describe('<SubscribeByRSS/>', () => {
|
||||
it('should be render links in dropdown', () => {
|
||||
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.comment__control {
|
||||
margin-right: 8px;
|
||||
color: var(--color39);
|
||||
color: var(--color33);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
import './input.css';
|
||||
|
||||
export { Input } from './input';
|
||||
export type { InputProps } from './input';
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.input {
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color31);
|
||||
margin: 0;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--color47);
|
||||
border-color: var(--color15);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--line-color);
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
background-color: rgb(var(--white-color));
|
||||
color: var(--color7);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--line-brighter-color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--color47);
|
||||
border-color: var(--color15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--color21);
|
||||
border-color: var(--line-color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color11);
|
||||
-webkit-text-fill-color: var(--color11);
|
||||
}
|
||||
}
|
||||
|
||||
.input:-webkit-autofill {
|
||||
box-shadow: 0 0 0 1000px rgb(var(--white-color)) inset;
|
||||
-webkit-text-fill-color: var(--color7);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1000px rgb(var(--white-color)) inset, 0 0 0 2px var(--color47);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
-webkit-text-fill-color: var(--color11);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
& .input {
|
||||
background-color: var(--color22);
|
||||
color: rgba(var(--white-color), 0.8);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color15);
|
||||
box-shadow: 0 0 0 2px var(--color47);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(var(--white-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
& .input:-webkit-autofill {
|
||||
box-shadow: 0 0 0 1000px var(--color22) inset;
|
||||
-webkit-text-fill-color: rgba(var(--white-color), 0.8);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1000px var(--color22) inset, 0 0 0 2px var(--color47);
|
||||
-webkit-text-fill-color: rgba(var(--white-color), 0.8);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
-webkit-text-fill-color: rgba(var(--white-color), 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invalid {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 2px var(--error-background);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import { h, JSX } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import b, { Mods, Mix } from 'bem-react-helper';
|
||||
import classnames from 'classnames/bind';
|
||||
|
||||
import type { Theme } from 'common/types';
|
||||
import styles from './input.module.css';
|
||||
|
||||
const cx = classnames.bind(styles);
|
||||
|
||||
export type InputProps = {
|
||||
kind?: 'primary' | 'secondary';
|
||||
theme?: Theme;
|
||||
mods?: Mods;
|
||||
mix?: Mix;
|
||||
invalid?: boolean;
|
||||
type?: string;
|
||||
} & Omit<JSX.HTMLAttributes, 'className'>;
|
||||
className?: string;
|
||||
} & JSX.HTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ children, theme, mods, mix, type = 'text', ...props }, ref) => (
|
||||
<input className={b('input', { mix }, { theme, ...mods })} type={type} {...props} ref={ref}>
|
||||
{children}
|
||||
</input>
|
||||
)
|
||||
export const Input = ({ children, className, type = 'text', invalid, ...props }: InputProps) => (
|
||||
<input className={cx(className, 'input', { invalid })} type={type} {...props}>
|
||||
{children}
|
||||
</input>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@ import { h, Component, FunctionComponent, Fragment } from 'preact';
|
||||
import { useSelector } from 'react-redux';
|
||||
import b from 'bem-react-helper';
|
||||
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import type { AuthProvider, Sorting } from 'common/types';
|
||||
import type { Sorting } from 'common/types';
|
||||
import type { StoreState } from 'store';
|
||||
import {
|
||||
COMMENT_NODE_CLASSNAME_PREFIX,
|
||||
MAX_SHOWN_ROOT_COMMENTS,
|
||||
@@ -14,18 +16,16 @@ import {
|
||||
import { maxShownComments, url } from 'common/settings';
|
||||
|
||||
import { StaticStore } from 'common/static-store';
|
||||
import { StoreState } from 'store';
|
||||
import {
|
||||
setUser,
|
||||
fetchUser,
|
||||
logout,
|
||||
logIn,
|
||||
blockUser,
|
||||
unblockUser,
|
||||
fetchBlockedUsers,
|
||||
hideUser,
|
||||
unhideUser,
|
||||
} from 'store/user/actions';
|
||||
import { fetchComments, updateSorting, addComment, updateComment } from 'store/comments/actions';
|
||||
import { fetchComments, updateSorting, addComment, updateComment, unsetCommentMode } from 'store/comments/actions';
|
||||
import { setCommentsReadOnlyState } from 'store/post-info/actions';
|
||||
import { setTheme } from 'store/theme/actions';
|
||||
|
||||
@@ -42,6 +42,7 @@ import { bindActions } from 'utils/actionBinder';
|
||||
import postMessage from 'utils/postMessage';
|
||||
import { useActions } from 'hooks/useAction';
|
||||
import { setCollapse } from 'store/thread/actions';
|
||||
import { logout } from 'components/auth/auth.api';
|
||||
|
||||
const mapStateToProps = (state: StoreState) => ({
|
||||
sort: state.comments.sort,
|
||||
@@ -68,10 +69,9 @@ const mapStateToProps = (state: StoreState) => ({
|
||||
const boundActions = bindActions({
|
||||
updateSorting,
|
||||
fetchComments,
|
||||
setUser,
|
||||
fetchUser,
|
||||
fetchBlockedUsers,
|
||||
logIn,
|
||||
logOut: logout,
|
||||
setTheme,
|
||||
setCommentsReadOnlyState,
|
||||
blockUser,
|
||||
@@ -81,6 +81,7 @@ const boundActions = bindActions({
|
||||
addComment,
|
||||
updateComment,
|
||||
setCollapse,
|
||||
unsetCommentMode,
|
||||
});
|
||||
|
||||
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions & { intl: IntlShape };
|
||||
@@ -144,16 +145,10 @@ export class Root extends Component<Props, State> {
|
||||
await this.props.updateSorting(sort);
|
||||
};
|
||||
|
||||
logIn = async (provider: AuthProvider) => {
|
||||
const user = await this.props.logIn(provider);
|
||||
|
||||
await this.props.fetchComments();
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
logOut = async () => {
|
||||
await this.props.logOut();
|
||||
logout = async () => {
|
||||
await logout();
|
||||
this.props.setUser();
|
||||
this.props.unsetCommentMode();
|
||||
localStorage.removeItem(LS_EMAIL_KEY);
|
||||
await this.props.fetchComments();
|
||||
};
|
||||
@@ -183,14 +178,16 @@ export class Root extends Component<Props, State> {
|
||||
};
|
||||
|
||||
onMessage(event: { data: string | object }) {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
if (data.theme && THEMES.includes(data.theme)) {
|
||||
this.props.setTheme(data.theme);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onBlockedUsersShow = async () => {
|
||||
@@ -221,18 +218,13 @@ export class Root extends Component<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is logged in via `Anonymous provider`
|
||||
*/
|
||||
isAnonymous = () => isUserAnonymous(this.props.user);
|
||||
|
||||
render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) {
|
||||
if (isUserLoading) {
|
||||
return <Preloader mix="root__preloader" />;
|
||||
}
|
||||
|
||||
const isCommentsDisabled = props.info.read_only!;
|
||||
const imageUploadHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
|
||||
const imageUploadHandler = isUserAnonymous(this.props.user) ? undefined : this.props.uploadImage;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -242,8 +234,7 @@ export class Root extends Component<Props, State> {
|
||||
onSortChange={this.changeSort}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
postInfo={this.props.info}
|
||||
onSignIn={this.logIn}
|
||||
onSignOut={this.logOut}
|
||||
onSignOut={this.logout}
|
||||
onBlockedUsersShow={this.onBlockedUsersShow}
|
||||
onBlockedUsersHide={this.onBlockedUsersHide}
|
||||
onCommentsChangeReadOnlyMode={this.props.setCommentsReadOnlyState}
|
||||
@@ -348,7 +339,7 @@ export const ConnectedRoot: FunctionComponent = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className={b('root', {}, { theme: props.theme })}>
|
||||
<div className={classnames(b('root', {}, { theme: props.theme }), props.theme)}>
|
||||
<Root {...props} {...actions} intl={intl} />
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
<FormattedMessage
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
:root {
|
||||
--color0: #000;
|
||||
--color1: #aaa;
|
||||
--color3: #e2efef;
|
||||
--color4: #edf6f7;
|
||||
--color5: #eee;
|
||||
--color6: #fff;
|
||||
--color0: #0f172a;
|
||||
--color8: #262626;
|
||||
--color22: #2d2d2c;
|
||||
--color24: #313133;
|
||||
--color7: #333;
|
||||
--color8: #22201c;
|
||||
--color9: #0aa;
|
||||
--color10: #777;
|
||||
--color11: #999;
|
||||
--color12: #259e06;
|
||||
--color13: #888;
|
||||
--color14: #6a6a6a;
|
||||
--color15: #259c9a;
|
||||
--color16: #dfe2e5;
|
||||
--color17: #099;
|
||||
--color23: #393734;
|
||||
--color18: #383838;
|
||||
--color19: #404040;
|
||||
--color20: #ddd;
|
||||
--color21: #f6f8fa;
|
||||
--color22: #2d2d2c;
|
||||
--color23: #393734;
|
||||
--color24: #313133;
|
||||
--color25: #9a0000;
|
||||
--color26: #ffd7d7;
|
||||
--color27: #f98989;
|
||||
--color28: #672323;
|
||||
--color29: #0e7e9d;
|
||||
--color30: #cc0606;
|
||||
--color31: #c4c4c4;
|
||||
--color32: #a6a6a6;
|
||||
--color33: #06c5c5;
|
||||
--color36: #575757;
|
||||
--color34: #555;
|
||||
--color35: #d9d9d9;
|
||||
--color36: #505050;
|
||||
--color37: #586069;
|
||||
--color38: #ef0000;
|
||||
--color39: #8cd4d4;
|
||||
--color40: #9cdddb;
|
||||
--color41: #efefef;
|
||||
--color42: #c6efef;
|
||||
--color43: #b7dddd;
|
||||
--color44: rgba(255, 255, 255, 0.3);
|
||||
--color45: rgba(0, 0, 0, 0.1);
|
||||
--color14: #6a6a6a;
|
||||
--color10: #777;
|
||||
--color13: #888;
|
||||
--color11: #969696;
|
||||
--color32: #a6a6a6;
|
||||
--color1: #aaa;
|
||||
--color35: #d1d5db;
|
||||
--color20: #ddd;
|
||||
--color46: rgba(27, 31, 35, 0.15);
|
||||
--color47: rgba(37, 156, 154, 0.4);
|
||||
--color45: rgba(0, 0, 0, 0.1);
|
||||
--color41: #efefef;
|
||||
--color5: #eee;
|
||||
--color44: rgba(255, 255, 255, 0.3);
|
||||
--color6: #fff;
|
||||
--color3: #e2efef;
|
||||
--color4: #edf6f7;
|
||||
--color16: #e2e8f0;
|
||||
--color31: #cbd5e1;
|
||||
--color21: #f1f5f9;
|
||||
--color29: #0e7e9d;
|
||||
--color9: #0aa;
|
||||
--color15: #099;
|
||||
--color33: #06c5c5;
|
||||
--color40: #9cdddb;
|
||||
--color43: #b7dddd;
|
||||
--color42: #c6efef;
|
||||
--color48: rgba(37, 156, 154, 0.6);
|
||||
--color47: rgba(37, 156, 154, 0.4);
|
||||
--color12: #259e06;
|
||||
--color28: #672323;
|
||||
--color25: #9a0000;
|
||||
--color30: #cc0606;
|
||||
--color38: #ef0000;
|
||||
--color27: #f98989;
|
||||
--color26: #ffd7d7;
|
||||
|
||||
/* code-highlight */
|
||||
--chroma-bg: rgba(0, 0, 0, 0.05);
|
||||
@@ -59,4 +57,24 @@
|
||||
--chroma-05: #859900;
|
||||
--chroma-06: #d33682;
|
||||
--chroma-07: #00aee2;
|
||||
|
||||
/* Named variables */
|
||||
--primary-color: 0, 170, 170;
|
||||
--primary-brighter-color: 0, 153, 153;
|
||||
--secondary-text-color: 100, 116, 139;
|
||||
--black-color: 0, 0, 0;
|
||||
--white-color: 255, 255, 255;
|
||||
--error-color: #b91c1c;
|
||||
--error-background: #ff466f2b;
|
||||
--line-color: var(--color16);
|
||||
--line-brighter-color: var(--color31);
|
||||
}
|
||||
|
||||
:root .dark {
|
||||
--line-color: var(--color36);
|
||||
--primary-color: 0, 153, 153;
|
||||
--primary-brighter-color: 0, 170, 170;
|
||||
--secondary-text-color: 209, 213, 219;
|
||||
--error-color: #ffa0a0;
|
||||
--line-brighter-color: var(--color11);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function createFrame({
|
||||
iframe.setAttribute('verticalscrolling', 'no');
|
||||
iframe.setAttribute(
|
||||
'style',
|
||||
'width: 1px !important; min-width: 100% !important; border: none !important; overflow: hidden !important;'
|
||||
'width: 1px !important; min-width: 100% !important; border: none !important; overflow: hidden !important; margin: -6px;'
|
||||
);
|
||||
|
||||
if (height) {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "даўжыня імя мусіць быць прынамсі 3 сімвалы",
|
||||
"anonymousLoginForm.log-in": "Увайсці",
|
||||
"anonymousLoginForm.symbol-limit": "імя карыстальніка мусіць пачынацца з літары і ўтрымоўваць толькі лацінскія літары, лічбы, знакі падкрэслівання і прабелы(?)",
|
||||
"anonymousLoginForm.user-name": "Імя карыстальніка",
|
||||
"authPanel.anonymous-provider": "Ананімна",
|
||||
"auth.back": "Назад",
|
||||
"auth.email-address": "Email адрас",
|
||||
"auth.loading": "Загрузка...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "або",
|
||||
"auth.signin": "Увайсці",
|
||||
"auth.submit": "Адправіць",
|
||||
"auth.symbols-restriction": "імя карыстальніка мусіць пачынацца з літары і ўтрымоўваць толькі лацінскія літары, лічбы, знакі падкрэслівання і прабелы(?)",
|
||||
"auth.user-not-found": "Карыстальнік не знойдзены",
|
||||
"auth.username": "Імя карыстальніка",
|
||||
"authPanel.disable-comments": "Адключыць каментары",
|
||||
"authPanel.disabled-cookies": "Адключыце блакаванне старонніх кукаў, каб увайсці або адкрыць каментары",
|
||||
"authPanel.enable-comments": "Уключыць каментары",
|
||||
"authPanel.enable-cookies": "Дазвольце кукі, каб увайсці і каментаваць",
|
||||
"authPanel.hide-settings": "Схаваць налады",
|
||||
"authPanel.logged-as": "Вы ўвайшлі як",
|
||||
"authPanel.login": "Уваход:",
|
||||
"authPanel.logout": "Выйсці?",
|
||||
"authPanel.new-page": "новая старонка",
|
||||
"authPanel.or-provider": "або",
|
||||
"authPanel.other-provider": "Іншы",
|
||||
"authPanel.read-only": "Толькі для чытання",
|
||||
"authPanel.request-to-delete-data": "Запытаць выдаленне маіх даных",
|
||||
"authPanel.show-settings": "Паказаць налады",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Старыя",
|
||||
"commentsSort.recently-updated": "Нядаўна абноўленыя",
|
||||
"commentsSort.worst": "Горшыя",
|
||||
"emailLoginForm.back": "Назад",
|
||||
"emailLoginForm.confirm": "Пацвердзіць",
|
||||
"emailLoginForm.email-address": "Email адрас",
|
||||
"emailLoginForm.empty-token": "Поле ўводу токену не можа быць пустым",
|
||||
"emailLoginForm.expired-token": "Час дзеяння токену сышоў",
|
||||
"emailLoginForm.invalid-email": "Уведзены несапраўдны email адрас",
|
||||
"emailLoginForm.loading": "Загрузка...",
|
||||
"emailLoginForm.send-verification": "Даслаць праверку",
|
||||
"emailLoginForm.token": "Токен",
|
||||
"emailLoginForm.user-not-found": "Карыстальнік не знойдзены",
|
||||
"errors.0": "Нешта пайшло не так. Калі ласка, паспрабуйце яшчэ раз крыху пазней.",
|
||||
"errors.1": "Каментар не знойдзены. Калі ласка, абнавіце старонку і паспрабуйце зноў.",
|
||||
"errors.10": "Час рэдагавання каментара сышоў.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Назад",
|
||||
"subscribeByEmail.close": "Закрыць",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Час дзеяння токена сышоў",
|
||||
"subscribeByEmail.have-been-subscribed": "Вы падпісаліся на абнаўленні па email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Вы адпісаліся ад абнаўленняў па email",
|
||||
"subscribeByEmail.only-registered-users": "Даступна толькі для зарэгістраваных карыстальнікаў",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Падпісацца па Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Падпісацца на адказы",
|
||||
"subscribeByEmail.subscribed": "Вы падпісаліся на абнаўленні па email",
|
||||
"subscribeByEmail.token": "Токен",
|
||||
"subscribeByEmail.unsubscribe": "Адпісацца",
|
||||
"subscribeByRSS.button-title": "Падпісацца па RSS",
|
||||
"subscribeByRSS.replies": "Адказы",
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Гутарка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Токен",
|
||||
"token.expired": "Час дзеяння токена сышоў",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Прымацаваць выяву, перацягніце або ўстаўце выяву з буфера абмену",
|
||||
"toolbar.bold": "Тлусты{shortcut}",
|
||||
"toolbar.code": "Код",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Потребителското име трябва да е от поне 3 символа",
|
||||
"anonymousLoginForm.log-in": "Вход",
|
||||
"anonymousLoginForm.symbol-limit": "Потребителското име трябва да започва с буква и да бъде само от латински букви, цифри, подчертавки или разтояния",
|
||||
"anonymousLoginForm.user-name": "Потребителско име",
|
||||
"authPanel.anonymous-provider": "Анонимен",
|
||||
"auth.back": "Обратно",
|
||||
"auth.email-address": "Адрес на електронна поща",
|
||||
"auth.loading": "Зареждане...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "или",
|
||||
"auth.signin": "Вход",
|
||||
"auth.submit": "Изпрати",
|
||||
"auth.symbols-restriction": "Потребителското име трябва да започва с буква и да бъде само от латински букви, цифри, подчертавки или разтояния",
|
||||
"auth.user-not-found": "Не бе намерен потребител",
|
||||
"auth.username": "Потребителско име",
|
||||
"authPanel.disable-comments": "Забрани коментарите",
|
||||
"authPanel.disabled-cookies": "Забрани бисквитки от трета страна блокиращи входа или коментарите",
|
||||
"authPanel.enable-comments": "Разреши коментарите",
|
||||
"authPanel.enable-cookies": "Разреши бисквитките за вход и коментар",
|
||||
"authPanel.hide-settings": "Скрий настройките",
|
||||
"authPanel.logged-as": "Вие влязохте като",
|
||||
"authPanel.login": "Вход:",
|
||||
"authPanel.logout": "Изход?",
|
||||
"authPanel.new-page": "нова страница",
|
||||
"authPanel.or-provider": "или",
|
||||
"authPanel.other-provider": "Друг",
|
||||
"authPanel.read-only": "Само за четене",
|
||||
"authPanel.request-to-delete-data": "Заявка за премахване на моите данни",
|
||||
"authPanel.show-settings": "Покажи настройките",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Най-стар",
|
||||
"commentsSort.recently-updated": "Най-скоро променен",
|
||||
"commentsSort.worst": "Най-лошия",
|
||||
"emailLoginForm.back": "Обратно",
|
||||
"emailLoginForm.confirm": "Потвърдете",
|
||||
"emailLoginForm.email-address": "Адрес на електронна поща",
|
||||
"emailLoginForm.empty-token": "Полето жетон не може да е празно",
|
||||
"emailLoginForm.expired-token": "Жетона е изтекъл",
|
||||
"emailLoginForm.invalid-email": "Адреса трябва да е валиден адрес на електронна поща",
|
||||
"emailLoginForm.loading": "Зареждане...",
|
||||
"emailLoginForm.send-verification": "Изпрати проверка",
|
||||
"emailLoginForm.token": "Жетон",
|
||||
"emailLoginForm.user-not-found": "Не бе намерен потребител",
|
||||
"errors.0": "Има някакъв проблем. Моля, опитайте отново по-късно.",
|
||||
"errors.1": "Коментара не бе намерен. Моля презаредете страницата и опитайте пак.",
|
||||
"errors.10": "Твърде късно е за редактиране на коментара.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Обратно",
|
||||
"subscribeByEmail.close": "Затвори",
|
||||
"subscribeByEmail.email": "Електронна поща",
|
||||
"subscribeByEmail.expired-token": "Изтекъл жетон",
|
||||
"subscribeByEmail.have-been-subscribed": "Вие се записахте да получавате новини по електронна поща",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Вие се отписахте да получавате новини по електронна поща",
|
||||
"subscribeByEmail.only-registered-users": "Достъпно само за регистрирани потребители",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Запиши се чрез електронна поща",
|
||||
"subscribeByEmail.subscribe-to-replies": "Запиши се за отговори",
|
||||
"subscribeByEmail.subscribed": "Вие сте записан да получавате новини по електронна поща",
|
||||
"subscribeByEmail.token": "Жетон",
|
||||
"subscribeByEmail.unsubscribe": "Отпиши се",
|
||||
"subscribeByRSS.button-title": "Запиши се чрез RSS",
|
||||
"subscribeByRSS.replies": "Отговори",
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Нишка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Жетон",
|
||||
"token.expired": "Изтекъл жетон",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Добави картина, премести или копирай от клипборда",
|
||||
"toolbar.bold": "Добави удебелен текст {shortcut}",
|
||||
"toolbar.code": "Вмъкни код",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Der Benutzername muss aus mindestens drei Buchstaben bestehen",
|
||||
"anonymousLoginForm.log-in": "Einloggen",
|
||||
"anonymousLoginForm.symbol-limit": "Der Benutzername muss mit einem Buchstaben beginnen und darf nur lateinische Buchstaben, Zahlen, Unterstriche und Leerzeichen enthalten",
|
||||
"anonymousLoginForm.user-name": "Benutzername",
|
||||
"authPanel.anonymous-provider": "Anonym",
|
||||
"auth.back": "Zurück",
|
||||
"auth.email-address": "E-Mailadresse",
|
||||
"auth.loading": "Laden...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "oder",
|
||||
"auth.signin": "Einloggen",
|
||||
"auth.submit": "Absenden",
|
||||
"auth.symbols-restriction": "Der Benutzername muss mit einem Buchstaben beginnen und darf nur lateinische Buchstaben, Zahlen, Unterstriche und Leerzeichen enthalten",
|
||||
"auth.user-not-found": "Benutzer konnte nicht gefunden werden",
|
||||
"auth.username": "Benutzername",
|
||||
"authPanel.disable-comments": "Kommentarfunktion deaktivieren",
|
||||
"authPanel.disabled-cookies": "Deaktiviere die Sperre von Drittanbieter-Cookies, um die Kommentarfunktion zu verwenden",
|
||||
"authPanel.enable-comments": "Kommentarfunktion aktivieren",
|
||||
"authPanel.enable-cookies": "Cookies zulassen, um die Kommentarfunktion zu verwenden",
|
||||
"authPanel.hide-settings": "Einstellungen verstecken",
|
||||
"authPanel.logged-as": "Du hast dich angemeldet als",
|
||||
"authPanel.login": "Anmelden:",
|
||||
"authPanel.logout": "Abmelden?",
|
||||
"authPanel.new-page": "neue Seite",
|
||||
"authPanel.or-provider": "oder",
|
||||
"authPanel.other-provider": "Andere",
|
||||
"authPanel.read-only": "Nur-Lesen",
|
||||
"authPanel.request-to-delete-data": "Löschung meiner Daten anfragen",
|
||||
"authPanel.show-settings": "Einstellungen anzeigen",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Älteste",
|
||||
"commentsSort.recently-updated": "Kürzlich geändert",
|
||||
"commentsSort.worst": "Schlechteste",
|
||||
"emailLoginForm.back": "Zurück",
|
||||
"emailLoginForm.confirm": "Bestätigen",
|
||||
"emailLoginForm.email-address": "E-Mailadresse",
|
||||
"emailLoginForm.empty-token": "Das Feld für den Token darf nicht leer sein",
|
||||
"emailLoginForm.expired-token": "Der Token ist abgelaufen",
|
||||
"emailLoginForm.invalid-email": "Bitte eine gültige E-Mailadresse eingeben",
|
||||
"emailLoginForm.loading": "Laden...",
|
||||
"emailLoginForm.send-verification": "E-Mailverifizierung anfragen",
|
||||
"emailLoginForm.token": "Der Token",
|
||||
"emailLoginForm.user-not-found": "Benutzer konnte nicht gefunden werden",
|
||||
"errors.0": "Leider ist etwas schiefgegangen. Bitte versuche es später erneut.",
|
||||
"errors.1": "Kommentar nicht gefunden. Bitte lade die Seite neu und versuche es erneut.",
|
||||
"errors.10": "Das Zeitfenster für das Bearbeiten des Kommentars ist verstrichen.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Zurück",
|
||||
"subscribeByEmail.close": "Schließen",
|
||||
"subscribeByEmail.email": "Mit einer E-Mailadresse",
|
||||
"subscribeByEmail.expired-token": "Der Token ist abgelaufen",
|
||||
"subscribeByEmail.have-been-subscribed": "Du hast soeben die Neuigkeiten per E-Mail abonniert",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Du hast soeben die Neuigkeiten per E-Mail abbestellt",
|
||||
"subscribeByEmail.only-registered-users": "Diese Funktion ist nur für registrierte Benutzer verfügbar",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Abonniere die Kommentare per E-Mail",
|
||||
"subscribeByEmail.subscribe-to-replies": "Abonniere die Antworten",
|
||||
"subscribeByEmail.subscribed": "Du hast die Neuigkeiten per E-Mail abonniert",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Abbestellen",
|
||||
"subscribeByRSS.button-title": "Abonniere die Kommentare als RSS-Feed",
|
||||
"subscribeByRSS.replies": "Antworten",
|
||||
"subscribeByRSS.site": "Seite",
|
||||
"subscribeByRSS.thread": "Thema",
|
||||
"subscribeByRSS.title": "RSS-Feed",
|
||||
"token": "Token",
|
||||
"token.expired": "Der Token ist abgelaufen",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Ein Bild einfügen, entweder per Drag & Drop oder aus der Zwischenablage",
|
||||
"toolbar.bold": "Text fett hervorheben {shortcut}",
|
||||
"toolbar.code": "Quelltext / Code einfügen",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
|
||||
"anonymousLoginForm.log-in": "Log in",
|
||||
"anonymousLoginForm.symbol-limit": "Username must contain only letters, numbers, underscores or spaces",
|
||||
"anonymousLoginForm.user-name": "Username",
|
||||
"authPanel.anonymous-provider": "Anonymous",
|
||||
"auth.back": "Back",
|
||||
"auth.email-address": "Email Address",
|
||||
"auth.loading": "Loading...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "or",
|
||||
"auth.signin": "Sign In",
|
||||
"auth.submit": "Submit",
|
||||
"auth.symbols-restriction": "Username must contain only letters, numbers, underscores or spaces",
|
||||
"auth.user-not-found": "No user was found",
|
||||
"auth.username": "Username",
|
||||
"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.new-page": "new page",
|
||||
"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",
|
||||
@@ -78,16 +80,6 @@
|
||||
"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.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"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",
|
||||
@@ -142,13 +133,15 @@
|
||||
"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",
|
||||
"token": "Token",
|
||||
"token.expired": "Token is expired",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
|
||||
"toolbar.bold": "Add bold text {shortcut}",
|
||||
"toolbar.code": "Insert a code",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "El nombre de usuario debe ser de al menos 3 caracteres de largo",
|
||||
"anonymousLoginForm.log-in": "Acceder",
|
||||
"anonymousLoginForm.symbol-limit": "El nombre de usuario debe comenzar con una letra y contener solamente letras latinas, números, guión bajo o espacio",
|
||||
"anonymousLoginForm.user-name": "Nombre de usuario",
|
||||
"authPanel.anonymous-provider": "Anónimo",
|
||||
"auth.back": "Volver",
|
||||
"auth.email-address": "Dirección de correo electrónico",
|
||||
"auth.loading": "Cargando...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "o",
|
||||
"auth.signin": "Acceder",
|
||||
"auth.submit": "Enviar",
|
||||
"auth.symbols-restriction": "El nombre de usuario debe comenzar con una letra y contener solamente letras latinas, números, guión bajo o espacio",
|
||||
"auth.user-not-found": "No se encontró el usuario",
|
||||
"auth.username": "Nombre de usuario",
|
||||
"authPanel.disable-comments": "Deshabilitar comentarios",
|
||||
"authPanel.disabled-cookies": "Deshabilita las cookies de terceros que bloquean el acceso o abre los comentarios en una",
|
||||
"authPanel.enable-comments": "Habilitar comentarios",
|
||||
"authPanel.enable-cookies": "Habilitar cookies para acceder y comentar",
|
||||
"authPanel.hide-settings": "Ocultar opciones",
|
||||
"authPanel.logged-as": "Accediste como",
|
||||
"authPanel.login": "Acceder:",
|
||||
"authPanel.logout": "¿Salir?",
|
||||
"authPanel.new-page": "nueva página",
|
||||
"authPanel.or-provider": "o",
|
||||
"authPanel.other-provider": "Otro",
|
||||
"authPanel.read-only": "Solo lectura",
|
||||
"authPanel.request-to-delete-data": "Solicitar la eliminación de mis datos",
|
||||
"authPanel.show-settings": "Mostrar opciones",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Más antiguo",
|
||||
"commentsSort.recently-updated": "Actualizado más recientemente",
|
||||
"commentsSort.worst": "Peor",
|
||||
"emailLoginForm.back": "Volver",
|
||||
"emailLoginForm.confirm": "Confirmar",
|
||||
"emailLoginForm.email-address": "Dirección de correo electrónico",
|
||||
"emailLoginForm.empty-token": "El campo de token no debe ser vacío",
|
||||
"emailLoginForm.expired-token": "El token ha expirado",
|
||||
"emailLoginForm.invalid-email": "La dirección de correo electrónica no es válida",
|
||||
"emailLoginForm.loading": "Cargando...",
|
||||
"emailLoginForm.send-verification": "Enviar verificación",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "No se encontró el usuario",
|
||||
"errors.0": "Algo salió mal. Por favor vuelve a intentar más tarde.",
|
||||
"errors.1": "No se ha encontrado el comentario. Por favor refresca la página y vuelve a intentar.",
|
||||
"errors.10": "Es muy tarde para editar el comentario.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Volver",
|
||||
"subscribeByEmail.close": "Cerrar",
|
||||
"subscribeByEmail.email": "Correo electrónico",
|
||||
"subscribeByEmail.expired-token": "Token expirado",
|
||||
"subscribeByEmail.have-been-subscribed": "Has sido suscripto a actualizaciones por correo electrónico",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Has sido de-suscripto a actualizaciones por correo electrónico",
|
||||
"subscribeByEmail.only-registered-users": "Disponible solo para usuarios registrados",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Suscribir por correo electrónico",
|
||||
"subscribeByEmail.subscribe-to-replies": "Suscribir a respuestas",
|
||||
"subscribeByEmail.subscribed": "Estás suscripto a actualizaciones por correo electrónico",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "De-suscribir",
|
||||
"subscribeByRSS.button-title": "Suscribir por RSS",
|
||||
"subscribeByRSS.replies": "Respuestas",
|
||||
"subscribeByRSS.site": "Sitio",
|
||||
"subscribeByRSS.thread": "Hilo",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token.expired": "Token expirado",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Adjunta la imágen, arrastra y suelta, o pega desde el portapapeles",
|
||||
"toolbar.bold": "Agrega texto en negrita {shortcut}",
|
||||
"toolbar.code": "Inserta un código",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Käyttäjätunnuksen on oltava vähintään 3 merkkiä pitkä",
|
||||
"anonymousLoginForm.log-in": "Kirjaudu sisään",
|
||||
"anonymousLoginForm.symbol-limit": "Käyttäjätunnuksen tulee alkaa kirjaimella ja sisältää vain latinalaisia kirjaimia, numeroita, alaviivoja ja välilyöntejä",
|
||||
"anonymousLoginForm.user-name": "Käyttäjätunnus",
|
||||
"authPanel.anonymous-provider": "Anonyymisti",
|
||||
"auth.back": "Takaisin",
|
||||
"auth.email-address": "Sähköposti",
|
||||
"auth.loading": "Ladataan...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "tai",
|
||||
"auth.signin": "Kirjaudu sisään",
|
||||
"auth.submit": "Lähetä",
|
||||
"auth.symbols-restriction": "Käyttäjätunnuksen tulee alkaa kirjaimella ja sisältää vain latinalaisia kirjaimia, numeroita, alaviivoja ja välilyöntejä",
|
||||
"auth.user-not-found": "Käyttäjää ei löytynyt",
|
||||
"auth.username": "Käyttäjätunnus",
|
||||
"authPanel.disable-comments": "Poista kommentit käytöstä",
|
||||
"authPanel.disabled-cookies": "Poista kolmansien osapuolien evästeiden estäminen käytöstä kirjautuaksesi tai kommenttien avataksesi",
|
||||
"authPanel.enable-comments": "Ota kommentit käyttöön",
|
||||
"authPanel.enable-cookies": "Salli evästeet kirjautuaksesi sisään ja kommentoidaksesi",
|
||||
"authPanel.hide-settings": "Piilota asetukset",
|
||||
"authPanel.logged-as": "Olet kirjautunut sisään nimellä",
|
||||
"authPanel.login": "Kirjaudu sisään:",
|
||||
"authPanel.logout": "Kirjaudu ulos?",
|
||||
"authPanel.new-page": "uusi sivu",
|
||||
"authPanel.or-provider": "tai",
|
||||
"authPanel.other-provider": "Muu",
|
||||
"authPanel.read-only": "Vain luku",
|
||||
"authPanel.request-to-delete-data": "Pyydä tietojen poistamista",
|
||||
"authPanel.show-settings": "Näytä asetukset",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Vanhin",
|
||||
"commentsSort.recently-updated": "Äskettäin päivitetty",
|
||||
"commentsSort.worst": "Huonoin",
|
||||
"emailLoginForm.back": "Takaisin",
|
||||
"emailLoginForm.confirm": "Vahvista",
|
||||
"emailLoginForm.email-address": "Sähköposti",
|
||||
"emailLoginForm.empty-token": "Tunnus-kenttä ei saa olla tyhjä",
|
||||
"emailLoginForm.expired-token": "Tunnus on vanhentunut",
|
||||
"emailLoginForm.invalid-email": "Anna kelvollinen sähköpostiosoite",
|
||||
"emailLoginForm.loading": "Ladataan...",
|
||||
"emailLoginForm.send-verification": "Lähetä vahvistus",
|
||||
"emailLoginForm.token": "Tunnus",
|
||||
"emailLoginForm.user-not-found": "Käyttäjää ei löytynyt",
|
||||
"errors.0": "Jotain meni pieleen. Yritä uudelleen myöhemmin.",
|
||||
"errors.1": "Kommenttia ei löydy. Päivitä sivu ja yritä uudelleen.",
|
||||
"errors.10": "Aikaikkuna kommentin muokkaamiseen on ohitettu.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Takaisin",
|
||||
"subscribeByEmail.close": "Sulje",
|
||||
"subscribeByEmail.email": "Sähköposti",
|
||||
"subscribeByEmail.expired-token": "Tunnus on vanhentunut",
|
||||
"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": "Saatavilla vain rekisteröityneille käyttäjille",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Tilaa sähköpostitse",
|
||||
"subscribeByEmail.subscribe-to-replies": "Tilaa vastaukset",
|
||||
"subscribeByEmail.subscribed": "Olet tilannut päivitykset sähköpostitse",
|
||||
"subscribeByEmail.token": "Tunnus",
|
||||
"subscribeByEmail.unsubscribe": "Lopeta tilaus",
|
||||
"subscribeByRSS.button-title": "Tilaa RSS",
|
||||
"subscribeByRSS.replies": "Vastaukset",
|
||||
"subscribeByRSS.site": "Sivusto",
|
||||
"subscribeByRSS.thread": "Teema",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Tunnus",
|
||||
"token.expired": "Tunnus on vanhentunut",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Liitä kuva, vedä ja pudota tai liitä leikepöydältä",
|
||||
"toolbar.bold": "Lihavoitu {shortcut}",
|
||||
"toolbar.code": "Lähdekoodi",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Nazwa użytkownika musi mieć co najmniej 3 litery",
|
||||
"anonymousLoginForm.log-in": "Zaloguj się",
|
||||
"anonymousLoginForm.symbol-limit": "Nazwa użytkownika powinna składać się z liter, numerów, podkreślinków lub spacji",
|
||||
"anonymousLoginForm.user-name": "Nazwa użytkownika",
|
||||
"authPanel.anonymous-provider": "Użytkownik anonimowy",
|
||||
"auth.back": "Powrót",
|
||||
"auth.email-address": "Adres email",
|
||||
"auth.loading": "Ładuję...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "lub",
|
||||
"auth.signin": "Zaloguj się",
|
||||
"auth.submit": "Potwierdź",
|
||||
"auth.symbols-restriction": "Nazwa użytkownika powinna składać się z liter, numerów, podkreślinków lub spacji",
|
||||
"auth.user-not-found": "Żaden użytkownik nie został znaleziony",
|
||||
"auth.username": "Nazwa użytkownika",
|
||||
"authPanel.disable-comments": "Wyłącz komentarze",
|
||||
"authPanel.disabled-cookies": "Wyłącz zewnętrzne blokowanie cookies w celu logowania lub otwierania w nich komentarzy",
|
||||
"authPanel.enable-comments": "Włącz komentarze",
|
||||
"authPanel.enable-cookies": "Zezwalaj na cookies w celu logowania i komentowania",
|
||||
"authPanel.hide-settings": "Ukryj ustawienia",
|
||||
"authPanel.logged-as": "Jesteś zalogowany jako",
|
||||
"authPanel.login": "Zaloguj:",
|
||||
"authPanel.logout": "Wyloguj?",
|
||||
"authPanel.new-page": "nowa strona",
|
||||
"authPanel.or-provider": "lub",
|
||||
"authPanel.other-provider": "Inny",
|
||||
"authPanel.read-only": "Tylko do odczytu",
|
||||
"authPanel.request-to-delete-data": "Zażądaj usunięcia swoich danych",
|
||||
"authPanel.show-settings": "Pokaż ustawienia",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Najstarsze",
|
||||
"commentsSort.recently-updated": "Ostatnio aktualizowane",
|
||||
"commentsSort.worst": "Najgorsze",
|
||||
"emailLoginForm.back": "Powrót",
|
||||
"emailLoginForm.confirm": "Potwierdź",
|
||||
"emailLoginForm.email-address": "Adres email",
|
||||
"emailLoginForm.empty-token": "Pole z tokenem nie może być puste",
|
||||
"emailLoginForm.expired-token": "Token wygasł",
|
||||
"emailLoginForm.invalid-email": "Adres powinien być poprawnym adresem email",
|
||||
"emailLoginForm.loading": "Ładuję...",
|
||||
"emailLoginForm.send-verification": "Wyślij weryfikację",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "Żaden użytkownik nie został znaleziony",
|
||||
"errors.0": "Coś poszło nie tak. Spróbuj ponownie później.",
|
||||
"errors.1": "Komentarz nie został znaleziony. Odśwież stronę i spróbuj ponownie.",
|
||||
"errors.10": "Jest już za późno żeby edytować ten komentarz.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Wróć",
|
||||
"subscribeByEmail.close": "Zamknij",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Wygasły token",
|
||||
"subscribeByEmail.have-been-subscribed": "Subskrybujesz aktualizacje przez email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Nie subskrybujesz aktualizacji przez email",
|
||||
"subscribeByEmail.only-registered-users": "Dostępne tylko dla zarejestrowanych użytkowników",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Zasubskrybuj używając email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Zasubskrybuj do odpowiedzi",
|
||||
"subscribeByEmail.subscribed": "Subskrybujesz aktualizacje przez email",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Wypisz się z subskrypcji",
|
||||
"subscribeByRSS.button-title": "Zasubskrybuj przez RSS",
|
||||
"subscribeByRSS.replies": "Odpowiedzi",
|
||||
"subscribeByRSS.site": "Strona",
|
||||
"subscribeByRSS.thread": "Wątek",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token.expired": "Wygasły token",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Załącz zdjęcie, przeciągnij i upuść lub wklej ze schowka",
|
||||
"toolbar.bold": "Dodaj pogrubienie {shortcut}",
|
||||
"toolbar.code": "Dodaj kod",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Длина имени должна быть больше 3 символов",
|
||||
"anonymousLoginForm.log-in": "Войти",
|
||||
"anonymousLoginForm.symbol-limit": "Имя пользователя должно начинаться с буквы и содержать только латинские буквы, цифры, знаки подчеркивания и пробелы",
|
||||
"anonymousLoginForm.user-name": "Имя пользователя",
|
||||
"authPanel.anonymous-provider": "Анонимно",
|
||||
"auth.back": "Назад",
|
||||
"auth.email-address": "Email адрес",
|
||||
"auth.loading": "Загрузка...",
|
||||
"auth.oauth-button": "Войти через {provider}",
|
||||
"auth.or": "или",
|
||||
"auth.signin": "Войти",
|
||||
"auth.submit": "Отправить",
|
||||
"auth.symbols-restriction": "Имя пользователя должно начинаться с буквы и содержать только латинские буквы, цифры, знаки подчеркивания и пробелы",
|
||||
"auth.user-not-found": "Пользователь не найден",
|
||||
"auth.username": "Имя пользователя",
|
||||
"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.new-page": "новая страница",
|
||||
"authPanel.or-provider": "или",
|
||||
"authPanel.other-provider": "Другой",
|
||||
"authPanel.read-only": "Только для чтения",
|
||||
"authPanel.request-to-delete-data": "Запросить удаление моих данных",
|
||||
"authPanel.show-settings": "Показать настройки",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Старые",
|
||||
"commentsSort.recently-updated": "Недавно обновленные",
|
||||
"commentsSort.worst": "Худшие",
|
||||
"emailLoginForm.back": "Назад",
|
||||
"emailLoginForm.confirm": "Подтвердить",
|
||||
"emailLoginForm.email-address": "Email адрес",
|
||||
"emailLoginForm.empty-token": "Поле ввода токена не должно быть пустым",
|
||||
"emailLoginForm.expired-token": "Время действия токена истекло",
|
||||
"emailLoginForm.invalid-email": "Введен некорректный email адрес",
|
||||
"emailLoginForm.loading": "Загрузка...",
|
||||
"emailLoginForm.send-verification": "Send Verification",
|
||||
"emailLoginForm.token": "Токен",
|
||||
"emailLoginForm.user-not-found": "Пользователь не найден",
|
||||
"errors.0": "Что-то пошло не так, попробуйте еще раз чуть позже.",
|
||||
"errors.1": "Комментарий не найден. Перезагрузите страницу и попробуйте еще раз.",
|
||||
"errors.10": "Время редактирования комментария истекло.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Назад",
|
||||
"subscribeByEmail.close": "Закрыть",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Время действия токена истекло",
|
||||
"subscribeByEmail.have-been-subscribed": "Вы были подписаны на обновления по email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Вы были отписаны от обновлений по email",
|
||||
"subscribeByEmail.only-registered-users": "Доступно только для зарегистрированных пользователей",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Подписаться по Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Подписаться на ответы",
|
||||
"subscribeByEmail.subscribed": "Вы были подписаны на обновления по email",
|
||||
"subscribeByEmail.token": "Токен",
|
||||
"subscribeByEmail.unsubscribe": "Отказаться от подписки",
|
||||
"subscribeByRSS.button-title": "Подписаться по RSS",
|
||||
"subscribeByRSS.replies": "Ответы",
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Ветка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Токен",
|
||||
"token.expired": "Время действия токена истекло",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Прикрепить изображение, перетащите или вставьте изображение из буфера обмена",
|
||||
"toolbar.bold": "Жирный {shortcut}",
|
||||
"toolbar.code": "Код",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Kullanıcı adı en az 3 karakter olmalı",
|
||||
"anonymousLoginForm.log-in": "Giriş yap",
|
||||
"anonymousLoginForm.symbol-limit": "Kullanıcı adı harf ile başlayıp; yalnızca harf, rakam, alt çizgi veya boşluk içerebilir",
|
||||
"anonymousLoginForm.user-name": "Kullanıcı adı",
|
||||
"authPanel.anonymous-provider": "Anonim",
|
||||
"auth.back": "Geri",
|
||||
"auth.email-address": "E-posta adresi",
|
||||
"auth.loading": "Yükleniyor...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "veya",
|
||||
"auth.signin": "Giriş yap",
|
||||
"auth.submit": "Gönder",
|
||||
"auth.symbols-restriction": "Kullanıcı adı harf ile başlayıp; yalnızca harf, rakam, alt çizgi veya boşluk içerebilir",
|
||||
"auth.user-not-found": "Kullanıcı bulunamadı",
|
||||
"auth.username": "Kullanıcı adı",
|
||||
"authPanel.disable-comments": "Yorumları devre dışı bırak",
|
||||
"authPanel.disabled-cookies": "Giriş yapabilmek ve yorum yapabilmek için çerezleri etkinleştirmeniz gerekmekte",
|
||||
"authPanel.enable-comments": "Yorumları etkinleştir",
|
||||
"authPanel.enable-cookies": "Giriş yapmak ve yorum yazmak için çerezleri etkinleştir",
|
||||
"authPanel.hide-settings": "Ayarları gizle",
|
||||
"authPanel.logged-as": "Kullanıcı adınız",
|
||||
"authPanel.login": "Giriş:",
|
||||
"authPanel.logout": "Çıkış yap?",
|
||||
"authPanel.new-page": "yeni sayfa",
|
||||
"authPanel.or-provider": "veya",
|
||||
"authPanel.other-provider": "Diğer",
|
||||
"authPanel.read-only": "Yazmaya kapalı",
|
||||
"authPanel.request-to-delete-data": "Bilgilerimi sil",
|
||||
"authPanel.show-settings": "Ayarları göster",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "En eskiler",
|
||||
"commentsSort.recently-updated": "En son güncellenen",
|
||||
"commentsSort.worst": "En kötüler",
|
||||
"emailLoginForm.back": "Geri",
|
||||
"emailLoginForm.confirm": "Onayla",
|
||||
"emailLoginForm.email-address": "E-posta adresi",
|
||||
"emailLoginForm.empty-token": "Parola bölümü boş olamaz",
|
||||
"emailLoginForm.expired-token": "Parola eski",
|
||||
"emailLoginForm.invalid-email": "Adres geçerli bir e-posta adresi olmalı",
|
||||
"emailLoginForm.loading": "Yükleniyor...",
|
||||
"emailLoginForm.send-verification": "Onayı gönder",
|
||||
"emailLoginForm.token": "Parola",
|
||||
"emailLoginForm.user-not-found": "Kullanıcı bulunamadı",
|
||||
"errors.0": "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
|
||||
"errors.1": "Yorum bulunamadı. Lütfen sayfayı yenileyip tekrar deneyin.",
|
||||
"errors.10": "Yorumu düzenlemek için artık çok geç.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Geri",
|
||||
"subscribeByEmail.close": "Kapat",
|
||||
"subscribeByEmail.email": "E-posta",
|
||||
"subscribeByEmail.expired-token": "Eski parola",
|
||||
"subscribeByEmail.have-been-subscribed": "Güncellemelere e-posta ile abone oldunuz",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Güncellemelere e-posta aboneliğiniz kaldırıldı",
|
||||
"subscribeByEmail.only-registered-users": "Yalnızca kayıtlı kullanıcılar erişebilir",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "E-posta ile abone ol",
|
||||
"subscribeByEmail.subscribe-to-replies": "Yanıtlara abone ol",
|
||||
"subscribeByEmail.subscribed": "Güncellemelere e-posta ile abone oldunuz",
|
||||
"subscribeByEmail.token": "Parola",
|
||||
"subscribeByEmail.unsubscribe": "Aboneliği kaldır",
|
||||
"subscribeByRSS.button-title": "RSS ile abone ol",
|
||||
"subscribeByRSS.replies": "Yanıtlar",
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Başlık",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Parola",
|
||||
"token.expired": "Eski parola",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Resim ekleyin, sürükleyip-bıraın veya kopyaladığınız resmi yapıştırın",
|
||||
"toolbar.bold": "Kalın yazı ekle {shortcut}",
|
||||
"toolbar.code": "Kod ekle",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Довжина імені повинна бути більше 3 символів",
|
||||
"anonymousLoginForm.log-in": "Увійти",
|
||||
"anonymousLoginForm.symbol-limit": "Ім’я користувача повинно починатися з літери і містити тільки латинські букви,цифри,знаки підкреслення і прогалини",
|
||||
"anonymousLoginForm.user-name": "Ім’я користувача",
|
||||
"authPanel.anonymous-provider": "Анонімно",
|
||||
"auth.back": "Назад",
|
||||
"auth.email-address": "Email адрес",
|
||||
"auth.loading": "Завантаження...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "або",
|
||||
"auth.signin": "Увійти",
|
||||
"auth.submit": "Відправити",
|
||||
"auth.symbols-restriction": "Ім’я користувача повинно починатися з літери і містити тільки латинські букви,цифри,знаки підкреслення і прогалини",
|
||||
"auth.user-not-found": "Користувач не знайдений",
|
||||
"auth.username": "Ім’я користувача",
|
||||
"authPanel.disable-comments": "Вімкнути коментарі",
|
||||
"authPanel.disabled-cookies": "Заборонені third-party cookies не дозволяють працювати коментарям",
|
||||
"authPanel.enable-comments": "Увімкнути коментари",
|
||||
"authPanel.enable-cookies": "Дозвольте Cookies",
|
||||
"authPanel.hide-settings": "Заховати налаштування",
|
||||
"authPanel.logged-as": "Ви увійшли як",
|
||||
"authPanel.login": "Вхід:",
|
||||
"authPanel.logout": "Вийти?",
|
||||
"authPanel.new-page": "нова сторінка",
|
||||
"authPanel.or-provider": "або",
|
||||
"authPanel.other-provider": "Інший",
|
||||
"authPanel.read-only": "Тільки для читання",
|
||||
"authPanel.request-to-delete-data": "Запросити видалення моїх даних",
|
||||
"authPanel.show-settings": "Показати налаштування",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "Старі",
|
||||
"commentsSort.recently-updated": "Нещодавно оновлені",
|
||||
"commentsSort.worst": "Гірші",
|
||||
"emailLoginForm.back": "Назад",
|
||||
"emailLoginForm.confirm": "Підтвердити",
|
||||
"emailLoginForm.email-address": "Email адрес",
|
||||
"emailLoginForm.empty-token": "Поле введення токена не повинно бути порожнім",
|
||||
"emailLoginForm.expired-token": "Час дії токена минув",
|
||||
"emailLoginForm.invalid-email": "Введений некоректний email адрес",
|
||||
"emailLoginForm.loading": "Завантаження...",
|
||||
"emailLoginForm.send-verification": "Відправити перевірку",
|
||||
"emailLoginForm.token": "Токен",
|
||||
"emailLoginForm.user-not-found": "Користувач не знайдений",
|
||||
"errors.0": "Щось пішло не так,спробуйте ще раз пізніше.",
|
||||
"errors.1": "Коментар не знайдений. Перезавантажте сторінку і спробуйте ще раз.",
|
||||
"errors.10": "Час редагування коментаря минув.",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "Назад",
|
||||
"subscribeByEmail.close": "Закрити",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "Час дії токена минув",
|
||||
"subscribeByEmail.have-been-subscribed": "Ви були підписані на оновлення по email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Ви були відписані від оновлень по email",
|
||||
"subscribeByEmail.only-registered-users": "Доступно тільки для зареєстрованих користувачів",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "Підписатися по Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Підписатися на відповіді",
|
||||
"subscribeByEmail.subscribed": "Ви були підписані на оновлення по email",
|
||||
"subscribeByEmail.token": "Токен",
|
||||
"subscribeByEmail.unsubscribe": "Відмовитися від підписки",
|
||||
"subscribeByRSS.button-title": "Підписатися по RSS",
|
||||
"subscribeByRSS.replies": "Відповіді",
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Ветка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Токен",
|
||||
"token.expired": "Час дії токена минув",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Прикріпити зображення,перетягніть або вставте зображення з буфера обміну",
|
||||
"toolbar.bold": "Жирний {shortcut}",
|
||||
"toolbar.code": "Код",
|
||||
|
||||
@@ -1,171 +1,163 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "Tên người dùng phải có ít nhất 3 ký tự",
|
||||
"anonymousLoginForm.log-in": "Log in",
|
||||
"anonymousLoginForm.symbol-limit": "Tên người dùng chỉ được chứa các chữ cái, số, dấu gạch dưới hoặc dấu cách",
|
||||
"anonymousLoginForm.user-name": "Tên người dùng",
|
||||
"authPanel.anonymous-provider": "Ẩn danh",
|
||||
"authPanel.disable-comments": "Tắt bình luận",
|
||||
"authPanel.disabled-cookies": "Tắt tính năng chặn cookies của bên thứ ba để đăng nhập hoặc mở nhận xét trong",
|
||||
"authPanel.enable-comments": "Mở bình luận",
|
||||
"authPanel.enable-cookies": "Cho phép cookies đăng nhập và bình luận",
|
||||
"authPanel.hide-settings": "Ẩn cài đặt",
|
||||
"authPanel.logged-as": "Đã đăng nhập bằng",
|
||||
"authPanel.login": "Đăng nhập:",
|
||||
"authPanel.logout": "Thoát?",
|
||||
"authPanel.new-page": "trang mới",
|
||||
"authPanel.or-provider": "hoặc",
|
||||
"authPanel.other-provider": "Khác",
|
||||
"authPanel.read-only": "Chỉ đọc",
|
||||
"authPanel.request-to-delete-data": "Yêu cầu xóa dữ liệu của tôi",
|
||||
"authPanel.show-settings": "Hiện cài đặt",
|
||||
"blockingDuration.day": "Trong một ngày",
|
||||
"blockingDuration.month": "Trong một tháng",
|
||||
"blockingDuration.permanently": "Vĩnh viễn",
|
||||
"blockingDuration.week": "Trong một tuần",
|
||||
"comment.block": "Chặn",
|
||||
"comment.block-user": "Bạn có muốn chặn {userName} {duration}?",
|
||||
"comment.blocked-user": "Đã chặn",
|
||||
"comment.blocking-period": "Thời gian chặn",
|
||||
"comment.cancel": "Huỷ",
|
||||
"comment.controversy": "Tranh luận: {value}",
|
||||
"comment.copied": "Đã sao chép!",
|
||||
"comment.copy": "Sao chép",
|
||||
"comment.delete": "Xoá",
|
||||
"comment.delete-message": "Bạn có muốn xoá bình luận này?",
|
||||
"comment.deleted-comment": "Bình luận này đã bị xoá",
|
||||
"comment.deleted-user": "Đã xoá",
|
||||
"comment.edit": "Sửa",
|
||||
"comment.expired-time": "Thời gian chỉnh sửa đã hết.",
|
||||
"comment.go-to-parent": "Đi tới bình luận chính",
|
||||
"comment.hide": "Ẩn",
|
||||
"comment.hide-user-comment": "Bạn có muốn ẩn bình luận của {userName}?",
|
||||
"comment.pin": "Ghim",
|
||||
"comment.pin-comment": "Bạn có muốn ghim bình luận này?",
|
||||
"comment.reply": "Trả lời",
|
||||
"comment.time": "{day} lúc {time}",
|
||||
"comment.toggle-verification": "Chuyển đổi xác thực",
|
||||
"comment.unblock": "Bỏ chặn",
|
||||
"comment.unblock-user": "Bạn có muốn bỏ chặn người dùng này?",
|
||||
"comment.unpin": "Bỏ ghim",
|
||||
"comment.unpin-comment": "Bạn có muốn bỏ ghim bình luận này?",
|
||||
"comment.unverified-user": "Người dùng chưa được xác thực",
|
||||
"comment.unverify-user": "Bạn có muốn bỏ xác thực {userName}?",
|
||||
"comment.verified-user": "Người dùng đã xác thực",
|
||||
"comment.verify-user": "Bạn có muốn xác thực {userName}?",
|
||||
"comment.vote-error": "Vote bị lỗi: {voteErrorMessage}",
|
||||
"commentForm.anonymous-uploading-disabled": "Upload ảnh bị tắt đối với người dùng ẩn danh. Vui lòng đăng nhập không phải ẩn danh để có thể đính kèm hình ảnh.",
|
||||
"commentForm.exceeded-size": "{fileName} vượt kích thước giới hạn {maxImageSize}",
|
||||
"commentForm.input-placeholder": "Nhập bình luận của bạn tại đây",
|
||||
"commentForm.new-comment": "Bình luận mới",
|
||||
"commentForm.notice-about-styling": "Hỗ trợ định dạng <a>Markdown</a>",
|
||||
"commentForm.preview": "Xem trước",
|
||||
"commentForm.reply": "Trả lời",
|
||||
"commentForm.save": "Lưu",
|
||||
"commentForm.send": "Gửi",
|
||||
"commentForm.subscribe-by": "Đăng kí bằng",
|
||||
"commentForm.subscribe-or": "hoặc",
|
||||
"commentForm.unauthorized-uploading-disabled": "Upload ảnh bị tắt đối với người dùng chưa đăng nhập. Hãy đăng nhập trước khi Upload.",
|
||||
"commentForm.unexpected-error": "Có gì đó sai sai. Vui lòng thử lại sau",
|
||||
"commentForm.upload-file-fail": "{fileName} upload thất bại với \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Đang tải lên...",
|
||||
"commentForm.uploading-file": "đang tải lên {fileName}...",
|
||||
"commentSort.sort-by": "Sắp xếp theo",
|
||||
"commentsSort.best": "Tốt nhất",
|
||||
"commentsSort.least-controversial": "Ít gây tranh cãi nhất",
|
||||
"commentsSort.least-recently-updated": "Gần đây nhất",
|
||||
"commentsSort.most-controversial": "Gây tranh cãi nhất",
|
||||
"commentsSort.newest": "Mới nhất",
|
||||
"commentsSort.oldest": "Cũ nhất",
|
||||
"commentsSort.recently-updated": "Mới được cập nhật",
|
||||
"commentsSort.worst": "Tệ nhất",
|
||||
"emailLoginForm.back": "Quay lại",
|
||||
"emailLoginForm.confirm": "Xác nhận",
|
||||
"emailLoginForm.email-address": "Địa chỉ Email",
|
||||
"emailLoginForm.empty-token": "Token không được bỏ trống",
|
||||
"emailLoginForm.expired-token": "Token đã hết hạn",
|
||||
"emailLoginForm.invalid-email": "Địa chỉ phải là địa chỉ Email chính xác",
|
||||
"emailLoginForm.loading": "Đang tải...",
|
||||
"emailLoginForm.send-verification": "Gửi xác thực",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "Không tìm thấy người dùng nào",
|
||||
"errors.0": "Có gì đó sai sai. Vui lòng thử lại sau.",
|
||||
"errors.1": "Bình luận không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.10": "Đã quá muộn để sửa bình luận.",
|
||||
"errors.11": "Nhận xét đã có trả lời, không thể chỉnh sửa.",
|
||||
"errors.12": "Không thể lưu kết quả vote. Một chút nữa rồi thử lại nhé.",
|
||||
"errors.13": "Bạn không thể vote bình luận của bạn.",
|
||||
"errors.14": "Bạn đã vote bình luận này rồi.",
|
||||
"errors.15": "Có quá nhiều vote cho bình luận.",
|
||||
"errors.16": "Bình luận đã đạt số điểm thấp nhất.",
|
||||
"errors.17": "Thao tác bị từ chối. Một chút nữa rồi thử lại nhé.",
|
||||
"errors.18": "Không tìm thấy file được yêu cầu.",
|
||||
"errors.2": "Yêu cầu đến không quản lý được.",
|
||||
"errors.3": "Bạn không có quyền thực hiện thao tác này.",
|
||||
"errors.4": "Dữ liệu bình luận không hợp lệ.",
|
||||
"errors.5": "Bình luận không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.6": "Trang không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.7": "Người dùng đã bị chặn.",
|
||||
"errors.8": "Người dùng đã bị chặn.",
|
||||
"errors.9": "Thay đổi bình luận không thành công. Một chút sau rồi thử lại nhé.",
|
||||
"errors.failed-fetch": "Nhận dữ liệu thất bại. Vui lòng kiểm tra lại đường truyền hoặc thử lại sau.",
|
||||
"errors.forbidden": "Bị cấm.",
|
||||
"errors.not-authorized": "Không được phép.",
|
||||
"errors.to-many-request": "Bạn đã đạt đến giới hạn yêu cầu tối đa.",
|
||||
"errors.unexpected-error": "Có gì đó sai sai.",
|
||||
"root.pinned-comments": "Bình buận đã ghim",
|
||||
"root.powered-by": "Powered by <a>Remark42</a>",
|
||||
"root.show-more": "Xem thêm",
|
||||
"settings.block": "chặn",
|
||||
"settings.block-time": "cho đến {day} lúc {time}",
|
||||
"settings.block-user": "Bạn có muốn chặn {userName}?",
|
||||
"settings.blocked-users-header": "Người dùng bị chặn:",
|
||||
"settings.blocked-users-title": "Người dùng bị chặn",
|
||||
"settings.hidden-user-header": "Người dùng đã ẩn:",
|
||||
"settings.hidden-users-title": "Người dùng đã ẩn",
|
||||
"settings.hide": "ẩn",
|
||||
"settings.no-blocked-users": "Không có người dùng bị chặn.",
|
||||
"settings.no-hidden-users": "Không có người dùng bị ẩn.",
|
||||
"settings.permanently": "vĩnh viễn",
|
||||
"settings.show": "hiện",
|
||||
"settings.unblock": "bỏ chặn",
|
||||
"settings.unblock-user": "Bạn có muốn bỏ chặn {userName}?",
|
||||
"settings.unknown": "không rõ",
|
||||
"subscribeByEmail.back": "Quay lại",
|
||||
"subscribeByEmail.close": "Đóng",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "token đã hết hạn",
|
||||
"subscribeByEmail.have-been-subscribed": "Bạn đã đăng kí nhận thông báo qua email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Bằng đã huỷ đăng kí nhận thông báo qua email",
|
||||
"subscribeByEmail.only-registered-users": "Chỉ có sẵn cho người dùng đã đăng ký",
|
||||
"subscribeByEmail.submit": "Gửi đi",
|
||||
"subscribeByEmail.subscribe": "Đăng kí",
|
||||
"subscribeByEmail.subscribe-by-email": "Đăng kí qua Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Đăng kí trả lời",
|
||||
"subscribeByEmail.subscribed": "Bạn đã đăng kí cập nhật qua email",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "Bỏ đăng kí",
|
||||
"subscribeByRSS.button-title": "Đăng kí qua RSS",
|
||||
"subscribeByRSS.replies": "Trả lời",
|
||||
"subscribeByRSS.site": "Trang",
|
||||
"subscribeByRSS.thread": "Chủ đề",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"toolbar.attach-image": "Đính kèm hình ảnh, kéo và thả hoặc dán từ khay nhớ tạm",
|
||||
"toolbar.bold": "Thêm chử đậm {shortcut}",
|
||||
"toolbar.code": "Chèn một đoạn code",
|
||||
"toolbar.header": "Thêm tiêu đề",
|
||||
"toolbar.italic": "Thêm chử nghiêng {shortcut}",
|
||||
"toolbar.link": "Thêm đường link {shortcut}",
|
||||
"toolbar.ordered-list": "Thêm danh sách số",
|
||||
"toolbar.quote": "Thêm trích dẫn",
|
||||
"toolbar.unordered-list": "Thêm danh sách",
|
||||
"user-info.last-comments": "Bình luận cuối cùng của {userName}",
|
||||
"user-info.unexpected-error": "Có gì đó sai sai",
|
||||
"vote.anonymous": "Người dùng ẩn danh không thể vote",
|
||||
"vote.deleted": "Không thể vote bình luận đã xoá",
|
||||
"vote.guest": "Đăng nhập để vote",
|
||||
"vote.only-positive": "Chỉ cho phép điểm số dương",
|
||||
"vote.only-post-page": "Chỉ được phép vote trên trang của bài đăng",
|
||||
"vote.own-comment": "Bạn không thể vote bình luận của bạn",
|
||||
"vote.readonly": "Không thể Vote chủ đề chỉ đọc"
|
||||
}
|
||||
|
||||
"auth.back": "Quay lại",
|
||||
"auth.email-address": "Địa chỉ Email",
|
||||
"auth.loading": "Đang tải...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "hoặc",
|
||||
"auth.signin": "Sign In",
|
||||
"auth.submit": "Gửi đi",
|
||||
"auth.symbols-restriction": "Tên người dùng chỉ được chứa các chữ cái, số, dấu gạch dưới hoặc dấu cách",
|
||||
"auth.user-not-found": "Không tìm thấy người dùng nào",
|
||||
"auth.username": "Tên người dùng",
|
||||
"authPanel.disable-comments": "Tắt bình luận",
|
||||
"authPanel.disabled-cookies": "Tắt tính năng chặn cookies của bên thứ ba để đăng nhập hoặc mở nhận xét trong",
|
||||
"authPanel.enable-comments": "Mở bình luận",
|
||||
"authPanel.enable-cookies": "Cho phép cookies đăng nhập và bình luận",
|
||||
"authPanel.hide-settings": "Ẩn cài đặt",
|
||||
"authPanel.logged-as": "Đã đăng nhập bằng",
|
||||
"authPanel.logout": "Thoát?",
|
||||
"authPanel.new-page": "trang mới",
|
||||
"authPanel.read-only": "Chỉ đọc",
|
||||
"authPanel.request-to-delete-data": "Yêu cầu xóa dữ liệu của tôi",
|
||||
"authPanel.show-settings": "Hiện cài đặt",
|
||||
"blockingDuration.day": "Trong một ngày",
|
||||
"blockingDuration.month": "Trong một tháng",
|
||||
"blockingDuration.permanently": "Vĩnh viễn",
|
||||
"blockingDuration.week": "Trong một tuần",
|
||||
"comment.block": "Chặn",
|
||||
"comment.block-user": "Bạn có muốn chặn {userName} {duration}?",
|
||||
"comment.blocked-user": "Đã chặn",
|
||||
"comment.blocking-period": "Thời gian chặn",
|
||||
"comment.cancel": "Huỷ",
|
||||
"comment.controversy": "Tranh luận: {value}",
|
||||
"comment.copied": "Đã sao chép!",
|
||||
"comment.copy": "Sao chép",
|
||||
"comment.delete": "Xoá",
|
||||
"comment.delete-message": "Bạn có muốn xoá bình luận này?",
|
||||
"comment.deleted-comment": "Bình luận này đã bị xoá",
|
||||
"comment.deleted-user": "Đã xoá",
|
||||
"comment.edit": "Sửa",
|
||||
"comment.expired-time": "Thời gian chỉnh sửa đã hết.",
|
||||
"comment.go-to-parent": "Đi tới bình luận chính",
|
||||
"comment.hide": "Ẩn",
|
||||
"comment.hide-user-comment": "Bạn có muốn ẩn bình luận của {userName}?",
|
||||
"comment.pin": "Ghim",
|
||||
"comment.pin-comment": "Bạn có muốn ghim bình luận này?",
|
||||
"comment.reply": "Trả lời",
|
||||
"comment.time": "{day} lúc {time}",
|
||||
"comment.toggle-verification": "Chuyển đổi xác thực",
|
||||
"comment.unblock": "Bỏ chặn",
|
||||
"comment.unblock-user": "Bạn có muốn bỏ chặn người dùng này?",
|
||||
"comment.unpin": "Bỏ ghim",
|
||||
"comment.unpin-comment": "Bạn có muốn bỏ ghim bình luận này?",
|
||||
"comment.unverified-user": "Người dùng chưa được xác thực",
|
||||
"comment.unverify-user": "Bạn có muốn bỏ xác thực {userName}?",
|
||||
"comment.verified-user": "Người dùng đã xác thực",
|
||||
"comment.verify-user": "Bạn có muốn xác thực {userName}?",
|
||||
"comment.vote-error": "Vote bị lỗi: {voteErrorMessage}",
|
||||
"commentForm.anonymous-uploading-disabled": "Upload ảnh bị tắt đối với người dùng ẩn danh. Vui lòng đăng nhập không phải ẩn danh để có thể đính kèm hình ảnh.",
|
||||
"commentForm.exceeded-size": "{fileName} vượt kích thước giới hạn {maxImageSize}",
|
||||
"commentForm.input-placeholder": "Nhập bình luận của bạn tại đây",
|
||||
"commentForm.new-comment": "Bình luận mới",
|
||||
"commentForm.notice-about-styling": "Hỗ trợ định dạng <a>Markdown</a>",
|
||||
"commentForm.preview": "Xem trước",
|
||||
"commentForm.reply": "Trả lời",
|
||||
"commentForm.save": "Lưu",
|
||||
"commentForm.send": "Gửi",
|
||||
"commentForm.subscribe-by": "Đăng kí bằng",
|
||||
"commentForm.subscribe-or": "hoặc",
|
||||
"commentForm.unauthorized-uploading-disabled": "Upload ảnh bị tắt đối với người dùng chưa đăng nhập. Hãy đăng nhập trước khi Upload.",
|
||||
"commentForm.unexpected-error": "Có gì đó sai sai. Vui lòng thử lại sau",
|
||||
"commentForm.upload-file-fail": "{fileName} upload thất bại với \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Đang tải lên...",
|
||||
"commentForm.uploading-file": "đang tải lên {fileName}...",
|
||||
"commentSort.sort-by": "Sắp xếp theo",
|
||||
"commentsSort.best": "Tốt nhất",
|
||||
"commentsSort.least-controversial": "Ít gây tranh cãi nhất",
|
||||
"commentsSort.least-recently-updated": "Gần đây nhất",
|
||||
"commentsSort.most-controversial": "Gây tranh cãi nhất",
|
||||
"commentsSort.newest": "Mới nhất",
|
||||
"commentsSort.oldest": "Cũ nhất",
|
||||
"commentsSort.recently-updated": "Mới được cập nhật",
|
||||
"commentsSort.worst": "Tệ nhất",
|
||||
"errors.0": "Có gì đó sai sai. Vui lòng thử lại sau.",
|
||||
"errors.1": "Bình luận không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.10": "Đã quá muộn để sửa bình luận.",
|
||||
"errors.11": "Nhận xét đã có trả lời, không thể chỉnh sửa.",
|
||||
"errors.12": "Không thể lưu kết quả vote. Một chút nữa rồi thử lại nhé.",
|
||||
"errors.13": "Bạn không thể vote bình luận của bạn.",
|
||||
"errors.14": "Bạn đã vote bình luận này rồi.",
|
||||
"errors.15": "Có quá nhiều vote cho bình luận.",
|
||||
"errors.16": "Bình luận đã đạt số điểm thấp nhất.",
|
||||
"errors.17": "Thao tác bị từ chối. Một chút nữa rồi thử lại nhé.",
|
||||
"errors.18": "Không tìm thấy file được yêu cầu.",
|
||||
"errors.2": "Yêu cầu đến không quản lý được.",
|
||||
"errors.3": "Bạn không có quyền thực hiện thao tác này.",
|
||||
"errors.4": "Dữ liệu bình luận không hợp lệ.",
|
||||
"errors.5": "Bình luận không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.6": "Trang không được tìm thấy, xin hãy làm mới trang và thử lại.",
|
||||
"errors.7": "Người dùng đã bị chặn.",
|
||||
"errors.8": "Người dùng đã bị chặn.",
|
||||
"errors.9": "Thay đổi bình luận không thành công. Một chút sau rồi thử lại nhé.",
|
||||
"errors.failed-fetch": "Nhận dữ liệu thất bại. Vui lòng kiểm tra lại đường truyền hoặc thử lại sau.",
|
||||
"errors.forbidden": "Bị cấm.",
|
||||
"errors.not-authorized": "Không được phép.",
|
||||
"errors.to-many-request": "Bạn đã đạt đến giới hạn yêu cầu tối đa.",
|
||||
"errors.unexpected-error": "Có gì đó sai sai.",
|
||||
"root.pinned-comments": "Bình buận đã ghim",
|
||||
"root.powered-by": "Powered by <a>Remark42</a>",
|
||||
"root.show-more": "Xem thêm",
|
||||
"settings.block": "chặn",
|
||||
"settings.block-time": "cho đến {day} lúc {time}",
|
||||
"settings.block-user": "Bạn có muốn chặn {userName}?",
|
||||
"settings.blocked-users-header": "Người dùng bị chặn:",
|
||||
"settings.blocked-users-title": "Người dùng bị chặn",
|
||||
"settings.hidden-user-header": "Người dùng đã ẩn:",
|
||||
"settings.hidden-users-title": "Người dùng đã ẩn",
|
||||
"settings.hide": "ẩn",
|
||||
"settings.no-blocked-users": "Không có người dùng bị chặn.",
|
||||
"settings.no-hidden-users": "Không có người dùng bị ẩn.",
|
||||
"settings.permanently": "vĩnh viễn",
|
||||
"settings.show": "hiện",
|
||||
"settings.unblock": "bỏ chặn",
|
||||
"settings.unblock-user": "Bạn có muốn bỏ chặn {userName}?",
|
||||
"settings.unknown": "không rõ",
|
||||
"subscribeByEmail.back": "Quay lại",
|
||||
"subscribeByEmail.close": "Đóng",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.have-been-subscribed": "Bạn đã đăng kí nhận thông báo qua email",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Bằng đã huỷ đăng kí nhận thông báo qua email",
|
||||
"subscribeByEmail.only-registered-users": "Chỉ có sẵn cho người dùng đã đăng ký",
|
||||
"subscribeByEmail.submit": "Gửi đi",
|
||||
"subscribeByEmail.subscribe": "Đăng kí",
|
||||
"subscribeByEmail.subscribe-by-email": "Đăng kí qua Email",
|
||||
"subscribeByEmail.subscribe-to-replies": "Đăng kí trả lời",
|
||||
"subscribeByEmail.subscribed": "Bạn đã đăng kí cập nhật qua email",
|
||||
"subscribeByEmail.unsubscribe": "Bỏ đăng kí",
|
||||
"subscribeByRSS.button-title": "Đăng kí qua RSS",
|
||||
"subscribeByRSS.replies": "Trả lời",
|
||||
"subscribeByRSS.site": "Trang",
|
||||
"subscribeByRSS.thread": "Chủ đề",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token.expired": "token đã hết hạn",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Đính kèm hình ảnh, kéo và thả hoặc dán từ khay nhớ tạm",
|
||||
"toolbar.bold": "Thêm chử đậm {shortcut}",
|
||||
"toolbar.code": "Chèn một đoạn code",
|
||||
"toolbar.header": "Thêm tiêu đề",
|
||||
"toolbar.italic": "Thêm chử nghiêng {shortcut}",
|
||||
"toolbar.link": "Thêm đường link {shortcut}",
|
||||
"toolbar.ordered-list": "Thêm danh sách số",
|
||||
"toolbar.quote": "Thêm trích dẫn",
|
||||
"toolbar.unordered-list": "Thêm danh sách",
|
||||
"user-info.last-comments": "Bình luận cuối cùng của {userName}",
|
||||
"user-info.unexpected-error": "Có gì đó sai sai",
|
||||
"vote.anonymous": "Người dùng ẩn danh không thể vote",
|
||||
"vote.deleted": "Không thể vote bình luận đã xoá",
|
||||
"vote.guest": "Đăng nhập để vote",
|
||||
"vote.only-positive": "Chỉ cho phép điểm số dương",
|
||||
"vote.only-post-page": "Chỉ được phép vote trên trang của bài đăng",
|
||||
"vote.own-comment": "Bạn không thể vote bình luận của bạn",
|
||||
"vote.readonly": "Không thể Vote chủ đề chỉ đọc"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"anonymousLoginForm.length-limit": "用户名最少需要3个字符",
|
||||
"anonymousLoginForm.log-in": "登录",
|
||||
"anonymousLoginForm.symbol-limit": "用户名必须以字母开头并且只能包含字母、数字、下划线或空格",
|
||||
"anonymousLoginForm.user-name": "用户名",
|
||||
"authPanel.anonymous-provider": "匿名",
|
||||
"auth.back": "返回",
|
||||
"auth.email-address": "Email地址",
|
||||
"auth.loading": "加载中...",
|
||||
"auth.oauth-button": "Sign In with {provider}",
|
||||
"auth.or": "或",
|
||||
"auth.signin": "登录",
|
||||
"auth.submit": "提交",
|
||||
"auth.symbols-restriction": "用户名必须以字母开头并且只能包含字母、数字、下划线或空格",
|
||||
"auth.user-not-found": "找不到用户",
|
||||
"auth.username": "用户名",
|
||||
"authPanel.disable-comments": "禁用评论",
|
||||
"authPanel.disabled-cookies": "禁用第三方cookie阻止登录或在其中打开评论",
|
||||
"authPanel.enable-comments": "启用评论",
|
||||
"authPanel.enable-cookies": "允许使用cookie登录并发表评论",
|
||||
"authPanel.hide-settings": "隐藏设置",
|
||||
"authPanel.logged-as": "您以以下身份登录",
|
||||
"authPanel.login": "登入:",
|
||||
"authPanel.logout": "登出?",
|
||||
"authPanel.new-page": "新页面",
|
||||
"authPanel.or-provider": "或",
|
||||
"authPanel.other-provider": "其它",
|
||||
"authPanel.read-only": "只读",
|
||||
"authPanel.request-to-delete-data": "请求删除我的数据",
|
||||
"authPanel.show-settings": "显示设置",
|
||||
@@ -78,16 +80,6 @@
|
||||
"commentsSort.oldest": "最旧",
|
||||
"commentsSort.recently-updated": "最近更新最多",
|
||||
"commentsSort.worst": "最差",
|
||||
"emailLoginForm.back": "返回",
|
||||
"emailLoginForm.confirm": "确认",
|
||||
"emailLoginForm.email-address": "Email地址",
|
||||
"emailLoginForm.empty-token": "Token字段不能为空",
|
||||
"emailLoginForm.expired-token": "Token已过期",
|
||||
"emailLoginForm.invalid-email": "Email地址应为有效的电子邮件地址",
|
||||
"emailLoginForm.loading": "加载中...",
|
||||
"emailLoginForm.send-verification": "发送验证",
|
||||
"emailLoginForm.token": "Token",
|
||||
"emailLoginForm.user-not-found": "找不到用户",
|
||||
"errors.0": "出了些问题,请稍后再试。",
|
||||
"errors.1": "找不到评论。 请刷新页面,然后重试。",
|
||||
"errors.10": "编辑评论为时已晚。",
|
||||
@@ -133,7 +125,6 @@
|
||||
"subscribeByEmail.back": "返回",
|
||||
"subscribeByEmail.close": "关闭",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.expired-token": "token已经过期",
|
||||
"subscribeByEmail.have-been-subscribed": "您已通过电子邮件订阅了更新通知",
|
||||
"subscribeByEmail.have-been-unsubscribed": "您已退订电子邮件更新通知",
|
||||
"subscribeByEmail.only-registered-users": "仅适用于注册用户",
|
||||
@@ -142,13 +133,15 @@
|
||||
"subscribeByEmail.subscribe-by-email": "通过邮件订阅",
|
||||
"subscribeByEmail.subscribe-to-replies": "订阅回复",
|
||||
"subscribeByEmail.subscribed": "您当前通过电子邮件订阅了更新通知",
|
||||
"subscribeByEmail.token": "Token",
|
||||
"subscribeByEmail.unsubscribe": "退订",
|
||||
"subscribeByRSS.button-title": "通过RSS订阅",
|
||||
"subscribeByRSS.replies": "回复",
|
||||
"subscribeByRSS.site": "站点",
|
||||
"subscribeByRSS.thread": "主题",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token.expired": "token已经过期",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "可以从剪贴板拖放或粘贴来上传图片",
|
||||
"toolbar.bold": "添加粗体 {shortcut}",
|
||||
"toolbar.code": "插入代码",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { NODE_ID, BASE_URL } from 'common/constants';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
import { getConfig } from 'common/api';
|
||||
import { fetchHiddenUsers } from 'store/user/actions';
|
||||
import { restoreProvider } from 'store/provider/actions';
|
||||
import { restoreCollapsedThreads } from 'store/thread/actions';
|
||||
import parseQuery from 'utils/parseQuery';
|
||||
|
||||
@@ -34,13 +33,9 @@ async function init(): Promise<void> {
|
||||
const params = parseQuery<{ page?: string; locale?: string }>();
|
||||
const locale = getLocale(params);
|
||||
const messages = await loadLocale(locale).catch(() => ({}));
|
||||
const boundActions = bindActionCreators({ fetchHiddenUsers, restoreCollapsedThreads }, reduxStore.dispatch);
|
||||
|
||||
const boundActions = bindActionCreators(
|
||||
{ fetchHiddenUsers, restoreProvider, restoreCollapsedThreads },
|
||||
reduxStore.dispatch
|
||||
);
|
||||
boundActions.fetchHiddenUsers();
|
||||
boundActions.restoreProvider();
|
||||
boundActions.restoreCollapsedThreads();
|
||||
|
||||
StaticStore.config = await getConfig();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { THEME_ACTIONS } from './theme/types';
|
||||
import { THREAD_ACTIONS } from './thread/types';
|
||||
import { USER_ACTIONS } from './user/types';
|
||||
import { USER_INFO_ACTIONS } from './user-info/types';
|
||||
import { PROVIDER_ACTIONS } from './provider/types';
|
||||
|
||||
/** Merged store actions */
|
||||
export type ACTIONS =
|
||||
@@ -13,5 +12,4 @@ export type ACTIONS =
|
||||
| THEME_ACTIONS
|
||||
| THREAD_ACTIONS
|
||||
| USER_ACTIONS
|
||||
| USER_INFO_ACTIONS
|
||||
| PROVIDER_ACTIONS;
|
||||
| USER_INFO_ACTIONS;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { setItem, getItem } from 'common/local-storage';
|
||||
import { StoreAction } from 'store';
|
||||
import { PROVIDER_UPDATE_ACTION, PROVIDER_UPDATE } from './types';
|
||||
|
||||
const PROVIDER_LOCALSTORAGE_KEY = '__remarkProvider';
|
||||
|
||||
/** saves last login provider from localstorage and put to store */
|
||||
export function updateProvider(payload: PROVIDER_UPDATE_ACTION['payload']): StoreAction<void, PROVIDER_UPDATE_ACTION> {
|
||||
return (dispatch) => {
|
||||
setItem(PROVIDER_LOCALSTORAGE_KEY, JSON.stringify(payload));
|
||||
dispatch({
|
||||
type: PROVIDER_UPDATE,
|
||||
payload,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** restores last login provider from localstorage and put to store */
|
||||
export function restoreProvider(): StoreAction<void, PROVIDER_UPDATE_ACTION> {
|
||||
return (dispatch) => {
|
||||
const payloadString = getItem(PROVIDER_LOCALSTORAGE_KEY);
|
||||
if (!payloadString) return;
|
||||
try {
|
||||
const payload = JSON.parse(payloadString);
|
||||
dispatch({
|
||||
type: PROVIDER_UPDATE,
|
||||
payload,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { provider } from './reducers';
|
||||
import { PROVIDER_UPDATE } from './types';
|
||||
|
||||
describe('provider reducer', () => {
|
||||
it('should set name of provider', () => {
|
||||
const result = provider(
|
||||
{ name: null },
|
||||
{
|
||||
type: PROVIDER_UPDATE,
|
||||
payload: {
|
||||
name: 'something',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
name: 'something',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { PROVIDER_ACTIONS, PROVIDER_UPDATE } from './types';
|
||||
|
||||
export interface ProviderState {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export function provider(state: ProviderState = { name: null }, action: PROVIDER_ACTIONS): ProviderState {
|
||||
switch (action.type) {
|
||||
case PROVIDER_UPDATE: {
|
||||
return { ...state, ...action.payload };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const PROVIDER_UPDATE = 'PROVIDER/UPDATE';
|
||||
export interface PROVIDER_UPDATE_ACTION {
|
||||
type: typeof PROVIDER_UPDATE;
|
||||
payload: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PROVIDER_ACTIONS = PROVIDER_UPDATE_ACTION;
|
||||
@@ -4,14 +4,12 @@ import * as theme from './theme/reducers';
|
||||
import * as user from './user/reducers';
|
||||
import * as userInfo from './user-info/reducers';
|
||||
import * as thread from './thread/reducers';
|
||||
import * as provider from './provider/reducers';
|
||||
|
||||
/** Merged store reducers */
|
||||
const rootProvider = {
|
||||
comments,
|
||||
...theme,
|
||||
...postInfo,
|
||||
...provider,
|
||||
...userInfo,
|
||||
...thread,
|
||||
...user,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as api from 'common/api';
|
||||
import { User, BlockedUser, AuthProvider, BlockTTL } from 'common/types';
|
||||
import { User, BlockedUser, BlockTTL } from 'common/types';
|
||||
import { ttlToTime } from 'utils/ttl-to-time';
|
||||
import getHiddenUsers from 'utils/get-hidden-users';
|
||||
import { LS_HIDDEN_USERS_KEY } from 'common/constants';
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
USER_SUBSCRIPTION_SET,
|
||||
USER_SET_ACTION,
|
||||
} from './types';
|
||||
import { unsetCommentMode, fetchComments } from '../comments/actions';
|
||||
import { updateProvider } from '../provider/actions';
|
||||
import { fetchComments } from '../comments/actions';
|
||||
import { COMMENTS_PATCH } from '../comments/types';
|
||||
|
||||
function setUser(user: User | null = null): USER_SET_ACTION {
|
||||
export function setUser(user: User | null = null): USER_SET_ACTION {
|
||||
return {
|
||||
type: USER_SET,
|
||||
user,
|
||||
@@ -34,20 +33,9 @@ export const fetchUser = (): StoreAction<Promise<User | null>> => async (dispatc
|
||||
return user;
|
||||
};
|
||||
|
||||
export const logIn = (provider: AuthProvider): StoreAction<Promise<User | null>> => async (dispatch) => {
|
||||
const user = await api.logIn(provider);
|
||||
|
||||
dispatch(updateProvider({ name: provider.name }));
|
||||
export const signin = (user: User): StoreAction<Promise<void>> => async (dispatch) => {
|
||||
dispatch(setUser(user));
|
||||
dispatch(fetchComments());
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const logout = (): StoreAction<Promise<void>> => async (dispatch) => {
|
||||
await api.logOut();
|
||||
dispatch(unsetCommentMode());
|
||||
dispatch(setUser());
|
||||
};
|
||||
|
||||
export const fetchBlockedUsers = (): StoreAction<Promise<BlockedUser[]>> => async (dispatch) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { getUser, logIn as logInApi, logOut } from 'common/api';
|
||||
import { getUser } from 'common/api';
|
||||
import { User } from 'common/types';
|
||||
|
||||
import { fetchUser, logIn, logout } from './actions';
|
||||
import { fetchUser, signin } from './actions';
|
||||
import { user } from './reducers';
|
||||
import { USER_ACTIONS, USER_SET } from './types';
|
||||
|
||||
jest.mock('common/api');
|
||||
|
||||
const getUserMock = (getUser as unknown) as jest.Mock<ReturnType<typeof getUser>>;
|
||||
const logInMock = (logInApi as unknown) as jest.Mock<ReturnType<typeof logInApi>>;
|
||||
const logOutMock = (logOut as unknown) as jest.Mock<ReturnType<typeof logOut>>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
@@ -45,48 +43,29 @@ describe('user', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state of user on logIn', async () => {
|
||||
logInMock.mockImplementation(
|
||||
async (): Promise<User> =>
|
||||
({
|
||||
id: 'john',
|
||||
name: 'John',
|
||||
admin: true,
|
||||
} as User)
|
||||
);
|
||||
it('should set state of user on signin', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
await logIn({ name: 'google' })(dispatch, getState, undefined);
|
||||
signin({
|
||||
name: 'Umputun',
|
||||
id: '1',
|
||||
picture: '',
|
||||
admin: true,
|
||||
ip: '',
|
||||
block: false,
|
||||
verified: true,
|
||||
})(dispatch, getState, undefined);
|
||||
expect(dispatch).toBeCalledWith({
|
||||
type: USER_SET,
|
||||
user: {
|
||||
id: 'john',
|
||||
name: 'John',
|
||||
name: 'Umputun',
|
||||
id: '1',
|
||||
picture: '',
|
||||
admin: true,
|
||||
ip: '',
|
||||
block: false,
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT set state of user on failed logIn', async () => {
|
||||
logInMock.mockImplementation(
|
||||
async (): Promise<User> => {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
await logIn({ name: 'google' })(dispatch, getState, undefined).catch(() => undefined);
|
||||
expect(dispatch).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unset user on logOut', async () => {
|
||||
logOutMock.mockImplementation(async (): Promise<void> => undefined);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
await logout()(dispatch, getState, undefined);
|
||||
expect(dispatch).toBeCalledWith({
|
||||
type: USER_SET,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import capitalizeFirstLetter from './capitalize-first-letter';
|
||||
|
||||
it('should capitalize first letter', () => {
|
||||
expect(capitalizeFirstLetter('one')).toBe('One');
|
||||
expect(capitalizeFirstLetter('один')).toBe('Один');
|
||||
expect(capitalizeFirstLetter('用户名最少需要3个字符')).toBe('用户名最少需要3个字符');
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function capitalizeFirstLetter(str: string): string {
|
||||
return `${str.charAt(0).toLocaleUpperCase()}${str.slice(1)}`;
|
||||
}
|
||||
@@ -1798,6 +1798,31 @@
|
||||
"unist-util-find-all-after": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "7.29.6",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.29.6.tgz",
|
||||
"integrity": "sha512-vzTsAXa439ptdvav/4lsKRcGpAQX7b6wBIqia7+iNzqGJ5zjswApxA6jDAsexrc6ue9krWcbh8o+LYkBXW+GCQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^4.2.0",
|
||||
"aria-query": "^4.2.2",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.4",
|
||||
"lz-string": "^1.4.4",
|
||||
"pretty-format": "^26.6.2"
|
||||
}
|
||||
},
|
||||
"@testing-library/preact": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-2.0.1.tgz",
|
||||
"integrity": "sha512-79kwVOY+3caoLgaPbiPzikjgY0Aya7Fc7TvGtR1upCnz2wrtmPDnN2t9vO7I7vDP2zoA+feSwOH5Q0BFErhaaQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@testing-library/dom": "^7.16.2"
|
||||
}
|
||||
},
|
||||
"@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -1810,6 +1835,12 @@
|
||||
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/aria-query": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
|
||||
"integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.12.tgz",
|
||||
@@ -3507,13 +3538,13 @@
|
||||
}
|
||||
},
|
||||
"call-bind": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz",
|
||||
"integrity": "sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.0"
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"callsites": {
|
||||
@@ -4877,6 +4908,12 @@
|
||||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dom-accessibility-api": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
|
||||
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -5137,23 +5174,25 @@
|
||||
}
|
||||
},
|
||||
"es-abstract": {
|
||||
"version": "1.18.0-next.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz",
|
||||
"integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==",
|
||||
"version": "1.18.0-next.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz",
|
||||
"integrity": "sha512-Ih4ZMFHEtZupnUh6497zEL4y2+w8+1ljnCyaTa+adcoafI1GOvMwFlDjBLfWR7y9VLfrjRJe9ocuHY1PSR9jjw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"call-bind": "^1.0.2",
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1",
|
||||
"is-callable": "^1.2.2",
|
||||
"is-negative-zero": "^2.0.0",
|
||||
"is-negative-zero": "^2.0.1",
|
||||
"is-regex": "^1.1.1",
|
||||
"object-inspect": "^1.8.0",
|
||||
"object-inspect": "^1.9.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.1",
|
||||
"string.prototype.trimend": "^1.0.1",
|
||||
"string.prototype.trimstart": "^1.0.1"
|
||||
"object.assign": "^4.1.2",
|
||||
"string.prototype.trimend": "^1.0.3",
|
||||
"string.prototype.trimstart": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"es-module-lexer": {
|
||||
@@ -8994,6 +9033,12 @@
|
||||
"yallist": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"lz-string": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
|
||||
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
|
||||
"dev": true
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@prefresh/babel-plugin": "^0.4.0",
|
||||
"@prefresh/webpack": "^3.0.1",
|
||||
"@size-limit/file": "^4.9.2",
|
||||
"@testing-library/preact": "^2.0.1",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/enzyme": "^3.10.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@keyframes bouncing {
|
||||
0%,
|
||||
80%,
|
||||
@@ -105,7 +109,7 @@
|
||||
}
|
||||
</style>
|
||||
<% if (htmlWebpackPlugin.options.env === 'production') { %>
|
||||
<link rel="stylesheet" href="remark.css" />
|
||||
<link rel="stylesheet" href="remark.css" />
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||