profile popup

This commit is contained in:
Pavel Mineev
2021-08-08 14:25:10 -05:00
committed by Umputun
parent 628dd898b6
commit 3a20bfcbb2
95 changed files with 1276 additions and 1063 deletions
+5
View File
@@ -12,6 +12,11 @@ module.exports = {
ignore: ['after-comment'],
},
],
'comment-empty-line-before': [
'always',
{ except: ['first-nested'], ignore: ['after-comment', 'stylelint-commands'] },
],
'value-keyword-case': ['lower', { ignoreProperties: ['composes'] }],
'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global'] }],
'property-no-unknown': [true, { ignoreProperties: ['composes'] }],
'mavrin/stylelint-declaration-use-css-custom-properties': {
-10
View File
@@ -1,10 +0,0 @@
jest.mock('react-intl', () => {
const messages = require('locales/en.json');
const reactIntl = jest.requireActual('react-intl');
const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {});
return {
...reactIntl,
useIntl: () => intlProvider.state.intl,
};
});
+2 -7
View File
@@ -11,13 +11,8 @@ export const getPostComments = (sort: Sorting) => apiFetcher.get<Tree>('/find',
export const getComment = (id: Comment['id']): Promise<Comment> => apiFetcher.get(`/id/${id}`, { url });
export const getUserComments = (
userId: User['id'],
limit: number
): Promise<{
comments: Comment[];
count: number;
}> => apiFetcher.get('/comments', { user: userId, limit });
export const getUserComments = (userId: User['id']): Promise<{ comments: Comment[] }> =>
apiFetcher.get('/comments', { user: userId, limit: 10 });
export const putCommentVote = ({ id, value }: { id: Comment['id']; value: number }): Promise<void> =>
apiFetcher.put(`/vote/${id}`, { url, vote: value });
+1 -1
View File
@@ -1,4 +1,4 @@
import { parseQuery } from 'utils/parseQuery';
import { parseQuery } from 'utils/parse-query';
import type { Theme } from './types';
import { THEMES, MAX_SHOWN_ROOT_COMMENTS } from './constants';
+2 -2
View File
@@ -10,7 +10,7 @@ export type User = {
};
/** data which is used on user-info page */
export type UserInfo = Pick<User, 'id' | 'name' | 'picture'>;
export type Profile = Pick<User, 'id' | 'name' | 'picture'> & { current?: '1' };
export interface BlockedUser {
id: string;
@@ -46,7 +46,7 @@ export interface Comment {
* if user hasn't voted delta will be 0,
* -1/+1 for downvote/upvote
*/
vote: number;
vote: 0 | 1 | -1;
/** comment controversy, read only */
controversy?: number;
/** pointer to have empty default in json response */
@@ -1,15 +0,0 @@
.auth-panel__user-id {
overflow: hidden;
text-overflow: ellipsis;
padding: 5px;
cursor: pointer;
}
.auth-panel__user-dropdown-title {
color: inherit;
&:hover {
color: inherit;
opacity: 0.9;
}
}
@@ -1,9 +0,0 @@
.auth-panel_logged-in {
font-size: 12px;
& .auth-panel__column {
&:first-child {
font-weight: 400;
}
}
}
@@ -1,5 +0,0 @@
.auth-panel_theme_dark {
& .auth-panel__user-id {
color: var(--color5);
}
}
@@ -1,5 +0,0 @@
.auth-panel_theme_light {
& .auth-panel__user-id {
color: var(--color13);
}
}
@@ -0,0 +1,28 @@
.user {
display: flex;
align-items: center;
font-size: 14px;
}
.userButton {
display: flex;
align-items: center;
font-weight: bold;
color: rgb(var(--secondary-text-color));
transition: color 0.15s;
&:hover {
color: inherit;
}
}
.userLogoutButton {
composes: userButton;
margin-left: 12px;
}
.userAvatar {
width: 20px;
height: 20px;
margin-right: 8px;
}
@@ -80,7 +80,7 @@ describe('<AuthPanel />', () => {
const userInfo = authPanelColumn.first();
expect(userInfo.text()).toEqual(expect.stringContaining('You logged in as John'));
expect(userInfo.text()).toEqual(expect.stringContaining('John'));
});
});
describe('For admin user', () => {
@@ -1,18 +1,23 @@
import { h, Component, Fragment } from 'preact';
import { h, Component } from 'preact';
import { useSelector } from 'react-redux';
import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { User, Sorting, Theme, PostInfo } from 'common/types';
import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants';
import { requestDeletion } from 'utils/email';
import { postMessageToParent } from 'utils/postMessage';
import { postMessageToParent } from 'utils/post-message';
import { getHandleClickProps } from 'common/accessibility';
import { StoreState } from 'store';
import { Dropdown, DropdownItem } from 'components/dropdown';
import { useTheme } from 'hooks/useTheme';
import { Button } from 'components/button';
import { Auth } from 'components/auth';
import { useTheme } from 'hooks/useTheme';
import { Avatar } from 'components/avatar';
import { SignOutIcon } from 'components/icons/signout';
import { IconButton } from 'components/icon-button/icon-button';
import { messages } from 'components/auth/auth.messsages';
import styles from './auth-panel.module.css';
interface OwnProps {
user: User | null;
@@ -20,8 +25,8 @@ interface OwnProps {
isCommentsDisabled: boolean;
postInfo: PostInfo;
signout(): Promise<void>;
onSortChange(s: Sorting): Promise<void>;
onSignOut(): Promise<void>;
onCommentsChangeReadOnlyMode(readOnly: boolean): Promise<void>;
onBlockedUsersShow(): void;
onBlockedUsersHide(): void;
@@ -72,46 +77,25 @@ class AuthPanelComponent extends Component<Props, State> {
this.props.onCommentsChangeReadOnlyMode(!this.props.isCommentsDisabled);
};
toggleUserInfoVisibility = () => {
const { user } = this.props;
if (!user) {
return;
}
postMessageToParent({ profile: user });
};
renderAuthorized = (user: User) => {
const { onSignOut, theme } = this.props;
const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_';
return (
<>
<FormattedMessage id="authPanel.logged-as" defaultMessage="You logged in as" />{' '}
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
<DropdownItem separator={!isUserAnonymous}>
<div
id={user.id}
className={b('auth-panel__user-id', {}, { theme })}
{...getHandleClickProps(this.toggleUserInfoVisibility)}
>
{user.id}
</div>
</DropdownItem>
{!isUserAnonymous && (
<DropdownItem>
<Button theme={theme} onClick={() => requestDeletion().then(onSignOut)}>
<FormattedMessage id="authPanel.request-to-delete-data" defaultMessage="Request my data removal" />
</Button>
</DropdownItem>
)}
</Dropdown>{' '}
<Button kind="link" theme={theme} onClick={onSignOut}>
<FormattedMessage id="authPanel.logout" defaultMessage="Logout?" />
</Button>
</>
<div className={clsx('user', styles.user)}>
<button
className={clsx('user-profile-button', styles.userButton)}
onClick={() => postMessageToParent({ profile: { ...user, current: '1' } })}
title={this.props.intl.formatMessage(messages.openProfile)}
>
<div className={clsx('user-avatar', styles.userAvatar)}>
<Avatar url={user.picture} />
</div>
{user.name}
</button>{' '}
<div className={clsx('user-logout-button', styles.userLogoutButton)}>
<IconButton title={this.props.intl.formatMessage(messages.signout)} onClick={this.props.signout}>
<SignOutIcon size="14" />
</IconButton>
</div>
</div>
);
};
@@ -136,7 +120,9 @@ class AuthPanelComponent extends Component<Props, State> {
};
renderCookiesWarning = () => {
if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null;
if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) {
return null;
}
return (
<div className="auth-panel__column">
<FormattedMessage id="authPanel.enable-cookies" defaultMessage="Allow cookies to login and comment" />
@@ -1,18 +1,10 @@
import './auth-panel.css';
import './__readonly-label/auth-panel__readonly-label.css';
import './__column/auth-panel__column.css';
import './__select/auth-panel__select.css';
import './__select-label/auth-panel__select-label.css';
import './__select-label-value/auth-panel__select-label-value.css';
import './__sort/auth-panel__sort.css';
import './__user-id/auth-panel__user-id.css';
import './_theme/_dark/auth-panel_theme_dark.css';
import './_theme/_light/auth-panel_theme_light.css';
import './_logged-in/auth-panel_logged-in.css';
export * from './auth-panel';
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { parseMessage, postMessageToParent } from 'utils/postMessage';
import { parseMessage, postMessageToParent } from 'utils/post-message';
function handleChangeIframeSize(element: HTMLElement) {
const { top } = element.getBoundingClientRect();
@@ -57,4 +57,12 @@ export const messages = defineMessages({
id: 'auth.submit',
defaultMessage: 'Submit',
},
openProfile: {
id: 'auth.open-profile',
defaultMessage: 'Open My Profile',
},
signout: {
id: 'auth.signout',
defaultMessage: 'Sign Out',
},
});
+4 -19
View File
@@ -25,15 +25,11 @@
left: 0;
min-width: 240px;
padding: 16px;
background-color: rgb(var(--white-color));
background-color: rgb(var(--primary-background-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;
@@ -148,16 +144,14 @@
.closeButton {
position: relative;
display: inline-block;
display: inline-flex;
width: 28px;
height: 28px;
border: 0;
padding: 0;
margin-left: auto;
background: unset;
cursor: pointer;
border-radius: 2px;
color: inherit;
justify-content: center;
align-items: center;
&:hover {
background-color: rgba(var(--primary-color), 0.1);
@@ -194,15 +188,6 @@
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;
+2 -1
View File
@@ -1,6 +1,7 @@
import '@testing-library/jest-dom';
import { h } from 'preact';
import { fireEvent, render, waitFor } from '@testing-library/preact';
import { fireEvent, waitFor } from '@testing-library/preact';
import { render } from 'tests/utils';
import { OAuthProvider, User } from 'common/types';
import { StaticStore } from 'common/static-store';
+6 -20
View File
@@ -1,11 +1,12 @@
import clsx from 'clsx';
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
import { useIntl } from 'react-intl';
import clsx from 'clsx';
import { useDispatch } from 'react-redux';
import { setUser } from 'store/user/actions';
import { Input } from 'components/input';
import { CrossIcon } from 'components/icons/cross';
import { TextareaAutosize } from 'components/textarea-autosize';
import { Button } from './components/button';
@@ -16,6 +17,7 @@ import { getProviders, getTokenInvalidReason } from './auth.utils';
import { emailSignin, verifyEmailSignin, anonymousSignin } from './auth.api';
import styles from './auth.module.css';
import { Spinner } from 'components/spinner/spinner';
export function Auth() {
const intl = useIntl();
@@ -107,15 +109,7 @@ export function Auth() {
<>
{errorMessage && <div className={clsx('auth-error', styles.error)}>{errorMessage}</div>}
<Button className="auth-submit" type="submit" disabled={isLoading}>
{isLoading ? (
<div
className={clsx('spinner', styles.spinner)}
role="presentation"
aria-label={intl.formatMessage(messages.loading)}
/>
) : (
intl.formatMessage(messages.submit)
)}
{isLoading ? <Spinner /> : intl.formatMessage(messages.submit)}
</Button>
</>
);
@@ -146,7 +140,7 @@ export function Auth() {
<>
<div className={clsx('auth-row', styles.row)}>
<div className={styles.backButton}>
<Button className="auth-back-button" size="small" kind="transparent" onClick={handleShowEmailStep}>
<Button className="auth-back-button" size="xs" kind="transparent" onClick={handleShowEmailStep}>
<svg
className={styles.backButtonArrow}
width="14"
@@ -171,15 +165,7 @@ export function Auth() {
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>
<CrossIcon />
</button>
</div>
<div className={clsx('auth-row', styles.row)}>
@@ -56,12 +56,16 @@
background-color: rgb(var(--primary-color));
}
.small {
.xs {
height: 28px;
font-size: 12px;
text-transform: uppercase;
}
.sm {
font-size: 14px;
}
.transparent {
background-color: rgba(var(--primary-color), 0.1);
color: rgb(var(--primary-color));
@@ -72,6 +76,17 @@
}
}
.link {
color: rgb(var(--primary-color));
background-color: unset;
text-transform: initial;
width: auto;
&:hover {
background-color: rgba(var(--primary-color), 0.1);
}
}
:global(.dark) {
& .button {
border-color: rgba(var(--white-color), 0.1);
@@ -4,8 +4,8 @@ import clsx from 'clsx';
import styles from './button.module.css';
type Props = Omit<JSX.HTMLAttributes<HTMLButtonElement>, 'size'> & {
size?: 'small';
kind?: 'transparent';
size?: 'xs' | 'sm';
kind?: 'transparent' | 'link';
suffix?: VNode;
loading?: boolean;
selected?: boolean;
@@ -1,5 +1,6 @@
import { h } from 'preact';
import { fireEvent, render, waitFor } from '@testing-library/preact';
import { fireEvent, waitFor } from '@testing-library/preact';
import { render } from 'tests/utils';
import type { User } from 'common/types';
import * as userActions from 'store/user/actions';
@@ -1,9 +1,6 @@
.avatar {
display: inline-block;
width: 24px;
height: 24px;
margin-right: 8px;
vertical-align: middle;
display: block;
max-width: 100%;
border-radius: 4px;
}
@@ -2,19 +2,18 @@ import '@testing-library/jest-dom';
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { BASE_URL } from 'common/constants.config';
import { Avatar } from './avatar';
import { BASE_URL } from 'common/constants.config';
describe('<Avatar/>', () => {
it('should have correct url', () => {
const { container } = render(<Avatar className="avatar" />);
const { container } = render(<Avatar />);
expect(container.querySelector('img')).toHaveAttribute('src', `${BASE_URL}/image.svg`);
});
it("shouldn't be accessible with screen reader", () => {
const { container } = render(<Avatar className="avatar" />);
const { container } = render(<Avatar />);
expect(container.querySelector('img')).toHaveAttribute('aria-hidden', 'true');
});
+2 -8
View File
@@ -8,19 +8,13 @@ import styles from './avatar.module.css';
type Props = {
url?: string;
/** className should be used only in puprose of put permanent class on Avatar for user themization */
className: string;
};
export function Avatar({ url, className }: Props) {
export function Avatar({ url }: Props) {
const avatarUrl = url || `${BASE_URL}${ghostIconUrl}`;
return (
// eslint-disable-next-line jsx-a11y/alt-text
<img
className={clsx('avatar', className, styles.avatar, !url && styles.avatarGhost)}
src={avatarUrl}
aria-hidden="true"
/>
<img className={clsx('avatar', styles.avatar, !url && styles.avatarGhost)} src={avatarUrl} aria-hidden="true" />
);
}
@@ -294,7 +294,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
type="submit"
disabled={!isValidEmailAddress || loading}
>
{loading ? <Preloader mix="comment-form__subscribe-by-email__preloader" /> : buttonLabel}
{loading ? <Preloader className="comment-form__subscribe-by-email__preloader" /> : buttonLabel}
</Button>
</form>
);
@@ -7,6 +7,17 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn((fn) => fn({ theme: 'light' })),
}));
jest.mock('react-intl', () => {
const messages = require('locales/en.json');
const reactIntl = jest.requireActual('react-intl');
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" />);
@@ -2,7 +2,6 @@
vertical-align: middle;
font-weight: 700;
text-decoration: none;
cursor: pointer;
&:hover {
opacity: 0.75;
@@ -1,6 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<g fill="none" fill-rule="evenodd">
<path fill="#4fbbd6" d="M5.823 14.822l-1.28.206a.824.824 0 0 1-.9-.52l-.463-1.212a.824.824 0 0 0-.476-.476l-1.212-.462a.824.824 0 0 1-.52-.9l.206-1.281a.824.824 0 0 0-.175-.65L.185 8.52a.824.824 0 0 1 0-1.04l.818-1.006a.824.824 0 0 0 .175-.65L.972 4.542a.824.824 0 0 1 .52-.9l1.212-.463a.824.824 0 0 0 .476-.476l.462-1.212a.824.824 0 0 1 .9-.52l1.281.206a.824.824 0 0 0 .65-.175L7.48.185a.824.824 0 0 1 1.04 0l1.006.818a.824.824 0 0 0 .65.175l1.281-.206a.824.824 0 0 1 .9.52l.463 1.212c.084.22.257.392.476.476l1.212.462c.365.14.582.515.52.9l-.206 1.281a.824.824 0 0 0 .175.65l.818 1.007a.824.824 0 0 1 0 1.04l-.818 1.006a.824.824 0 0 0-.175.65l.206 1.281a.824.824 0 0 1-.52.9l-1.212.463a.824.824 0 0 0-.476.476l-.462 1.212a.824.824 0 0 1-.9.52l-1.281-.206a.824.824 0 0 0-.65.175l-1.007.818a.824.824 0 0 1-1.04 0l-1.006-.818a.824.824 0 0 0-.65-.175z"/>
<path stroke="#FFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M4.755 8.252L7 10.5l4.495-4.495"/>
</g>
<path fill="#4fbbd6" d="M5.823 14.822l-1.28.206a.824.824 0 0 1-.9-.52l-.463-1.212a.824.824 0 0 0-.476-.476l-1.212-.462a.824.824 0 0 1-.52-.9l.206-1.281a.824.824 0 0 0-.175-.65L.185 8.52a.824.824 0 0 1 0-1.04l.818-1.006a.824.824 0 0 0 .175-.65L.972 4.542a.824.824 0 0 1 .52-.9l1.212-.463a.824.824 0 0 0 .476-.476l.462-1.212a.824.824 0 0 1 .9-.52l1.281.206a.824.824 0 0 0 .65-.175L7.48.185a.824.824 0 0 1 1.04 0l1.006.818a.824.824 0 0 0 .65.175l1.281-.206a.824.824 0 0 1 .9.52l.463 1.212c.084.22.257.392.476.476l1.212.462c.365.14.582.515.52.9l-.206 1.281a.824.824 0 0 0 .175.65l.818 1.007a.824.824 0 0 1 0 1.04l-.818 1.006a.824.824 0 0 0-.175.65l.206 1.281a.824.824 0 0 1-.52.9l-1.212.463a.824.824 0 0 0-.476.476l-.462 1.212a.824.824 0 0 1-.9.52l-1.281-.206a.824.824 0 0 0-.65.175l-1.007.818a.824.824 0 0 1-1.04 0l-1.006-.818a.824.824 0 0 0-.65-.175z" />
<path stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M4.755 8.252L7 10.5l4.495-4.495" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -14,6 +14,12 @@
}
}
.comment__avatar {
width: 24px;
height: 24px;
margin-right: 8px;
}
.comment_level_6 {
& .comment__text,
& .comment__actions {
@@ -1,5 +1,17 @@
import { h } from 'preact';
import { mount } from 'enzyme';
jest.mock('react-intl', () => {
const messages = require('locales/en.json');
const reactIntl = jest.requireActual('react-intl');
const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {});
return {
...reactIntl,
useIntl: () => intlProvider.state.intl,
};
});
import { useIntl, IntlProvider } from 'react-intl';
import enMessages from 'locales/en.json';
@@ -24,7 +36,6 @@ function mountComment(props: CommentProps) {
}
const DefaultProps: Partial<CommentProps> = {
CommentForm: null,
post_info: {
read_only: false,
} as PostInfo,
+90 -100
View File
@@ -2,7 +2,7 @@ import { h, JSX, Component, createRef, ComponentType } from 'preact';
import b from 'bem-react-helper';
import { getHandleClickProps } from 'common/accessibility';
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
import { COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
import { StaticStore } from 'common/static-store';
import { debounce } from 'utils/debounce';
@@ -16,7 +16,7 @@ import { Avatar } from 'components/avatar';
import { Button } from 'components/button';
import { Countdown } from 'components/countdown';
import { getPreview, uploadImage } from 'common/api';
import { postMessageToParent } from 'utils/postMessage';
import { postMessageToParent } from 'utils/post-message';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import { getVoteMessage, VoteMessagesTypes } from './getVoteMessage';
import { getBlockingDurations } from './getBlockingDurations';
@@ -24,79 +24,12 @@ import { boundActions } from './connected-comment';
import './styles';
const messages = defineMessages({
deleteMessage: {
id: 'comment.delete-message',
defaultMessage: 'Do you want to delete this comment?',
},
hideUserComments: {
id: 'comment.hide-user-comment',
defaultMessage: 'Do you want to hide comments of {userName}?',
},
pinComment: {
id: 'comment.pin-comment',
defaultMessage: 'Do you want to pin this comment?',
},
unpinComment: {
id: 'comment.unpin-comment',
defaultMessage: 'Do you want to unpin this comment?',
},
verifyUser: {
id: 'comment.verify-user',
defaultMessage: 'Do you want to verify {userName}?',
},
unverifyUser: {
id: 'comment.unverify-user',
defaultMessage: 'Do you want to unverify {userName}?',
},
blockUser: {
id: 'comment.block-user',
defaultMessage: 'Do you want to block {userName} {duration}?',
},
unblockUser: {
id: 'comment.unblock-user',
defaultMessage: 'Do you want to unblock this user?',
},
deletedComment: {
id: 'comment.deleted-comment',
defaultMessage: 'This comment was deleted',
},
controversy: {
id: 'comment.controversy',
defaultMessage: 'Controversy: {value}',
},
toggleVerification: {
id: 'comment.toggle-verification',
defaultMessage: 'Toggle verification',
},
verifiedUser: {
id: 'comment.verified-user',
defaultMessage: 'Verified user',
},
unverifiedUser: {
id: 'comment.unverified-user',
defaultMessage: 'Unverified user',
},
goToParent: {
id: 'comment.go-to-parent',
defaultMessage: 'Go to parent comment',
},
expiredTime: {
id: 'comment.expired-time',
defaultMessage: 'Editing time has expired.',
},
commentTime: {
id: 'comment.time',
defaultMessage: '{day} at {time}',
},
});
export type CommentProps = {
user: User | null;
CommentForm: ComponentType<CommentFormProps> | null;
CommentForm?: ComponentType<CommentFormProps>;
data: CommentType;
repliesCount?: number;
post_info: PostInfo | null;
post_info?: PostInfo;
/** whether comment's user is banned */
isUserBanned?: boolean;
isCommentsDisabled: boolean;
@@ -418,7 +351,7 @@ export class Comment extends Component<CommentProps, State> {
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.post_info?.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (StaticStore.config.positive_score && this.props.data.score < 1)
@@ -435,7 +368,7 @@ export class Comment extends Component<CommentProps, State> {
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.post_info?.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
@@ -463,14 +396,14 @@ export class Comment extends Component<CommentProps, State> {
<FormattedMessage id="comment.copied" defaultMessage="Copied!" />
</span>
) : (
<Button kind="link" {...getHandleClickProps(this.copyComment)} mix="comment__control">
<Button kind="link" onClick={this.copyComment} mix="comment__control">
<FormattedMessage id="comment.copy" defaultMessage="Copy" />
</Button>
)
);
controls.push(
<Button kind="link" {...getHandleClickProps(this.togglePin)} mix="comment__control">
<Button kind="link" onClick={this.togglePin} mix="comment__control">
{this.props.data.pin ? (
<FormattedMessage id="comment.unpin" defaultMessage="Unpin" />
) : (
@@ -482,7 +415,7 @@ export class Comment extends Component<CommentProps, State> {
if (!isCurrentUser) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.hideUser)} mix="comment__control">
<Button kind="link" onClick={this.hideUser} mix="comment__control">
<FormattedMessage id="comment.hide" defaultMessage="Hide" />
</Button>
);
@@ -491,7 +424,7 @@ export class Comment extends Component<CommentProps, State> {
if (isAdmin) {
if (this.props.isUserBanned) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.onUnblockUserClick)} mix="comment__control">
<Button kind="link" onClick={this.onUnblockUserClick} mix="comment__control">
<FormattedMessage id="comment.unblock" defaultMessage="Unblock" />
</Button>
);
@@ -517,7 +450,7 @@ export class Comment extends Component<CommentProps, State> {
if (!this.props.data.delete) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.deleteComment)} mix="comment__control">
<Button kind="link" onClick={this.deleteComment} mix="comment__control">
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
</Button>
);
@@ -543,7 +476,7 @@ export class Comment extends Component<CommentProps, State> {
const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
const commentControls = this.getCommentControls();
const intl = props.intl;
const CommentForm = this.props.CommentForm;
const CommentForm = this.props.CommentForm || null;
/**
* CommentType adapted for rendering
@@ -574,13 +507,7 @@ export class Comment extends Component<CommentProps, State> {
sign: !scoreSignEnabled ? '' : state.cachedScore > 0 ? '+' : state.cachedScore < 0 ? '' : null,
view: state.cachedScore > 0 ? 'positive' : state.cachedScore < 0 ? 'negative' : undefined,
},
user: {
...props.data.user,
picture:
props.data.user.picture.indexOf(API_BASE) === 0
? `${BASE_URL}${props.data.user.picture}`
: props.data.user.picture,
},
user: props.data.user,
};
const defaultMods = {
@@ -646,6 +573,7 @@ export class Comment extends Component<CommentProps, State> {
);
}
const goToParentMessage = intl.formatMessage(messages.goToParent);
return (
<article
className={b('comment', { mix: this.props.mix }, defaultMods)}
@@ -659,30 +587,25 @@ export class Comment extends Component<CommentProps, State> {
</div>
)}
<div className="comment__info">
{props.view !== 'user' && !props.collapsed && <Avatar className="comment__avatar" url={o.user.picture} />}
{props.view !== 'user' && !props.collapsed && (
<div className="comment__avatar">
<Avatar url={o.user.picture} />
</div>
)}
{props.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleUserInfoVisibility)}
className="comment__username"
title={o.user.id}
>
<button onClick={() => this.toggleUserInfoVisibility()} className="comment__username">
{o.user.name}
</span>
</button>
)}
{isAdmin && props.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleVerify)}
aria-label={intl.formatMessage(messages.toggleVerification)}
title={
o.user.verified
? intl.formatMessage(messages.verifiedUser)
: intl.formatMessage(messages.unverifiedUser)
}
title={intl.formatMessage(o.user.verified ? messages.verifiedUser : messages.unverifiedUser)}
className={b('comment__verification', {}, { active: o.user.verified, clickable: true })}
/>
)}
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
<span
title={intl.formatMessage(messages.verifiedUser)}
@@ -774,7 +697,7 @@ export class Comment extends Component<CommentProps, State> {
{(!props.collapsed || props.view === 'pinned') && (
<div className="comment__actions">
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && props.view === 'main' && (
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
<Button kind="link" onClick={this.toggleReplying} mix="comment__action">
{isReplying ? (
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
) : (
@@ -886,3 +809,70 @@ function getLocalDatetime(intl: IntlShape, date: Date) {
time: intl.formatTime(date),
});
}
const messages = defineMessages({
deleteMessage: {
id: 'comment.delete-message',
defaultMessage: 'Do you want to delete this comment?',
},
hideUserComments: {
id: 'comment.hide-user-comment',
defaultMessage: 'Do you want to hide comments of {userName}?',
},
pinComment: {
id: 'comment.pin-comment',
defaultMessage: 'Do you want to pin this comment?',
},
unpinComment: {
id: 'comment.unpin-comment',
defaultMessage: 'Do you want to unpin this comment?',
},
verifyUser: {
id: 'comment.verify-user',
defaultMessage: 'Do you want to verify {userName}?',
},
unverifyUser: {
id: 'comment.unverify-user',
defaultMessage: 'Do you want to unverify {userName}?',
},
blockUser: {
id: 'comment.block-user',
defaultMessage: 'Do you want to block {userName} {duration}?',
},
unblockUser: {
id: 'comment.unblock-user',
defaultMessage: 'Do you want to unblock this user?',
},
deletedComment: {
id: 'comment.deleted-comment',
defaultMessage: 'This comment was deleted',
},
controversy: {
id: 'comment.controversy',
defaultMessage: 'Controversy: {value}',
},
toggleVerification: {
id: 'comment.toggle-verification',
defaultMessage: 'Toggle verification',
},
verifiedUser: {
id: 'comment.verified-user',
defaultMessage: 'Verified user',
},
unverifiedUser: {
id: 'comment.unverified-user',
defaultMessage: 'Unverified user',
},
goToParent: {
id: 'comment.go-to-parent',
defaultMessage: 'Go to parent comment',
},
expiredTime: {
id: 'comment.expired-time',
defaultMessage: 'Editing time has expired.',
},
commentTime: {
id: 'comment.time',
defaultMessage: '{day} at {time}',
},
});
@@ -4,7 +4,7 @@ import b from 'bem-react-helper';
import { Theme } from 'common/types';
import { sleep } from 'utils/sleep';
import { Button } from 'components/button';
import { parseMessage } from 'utils/postMessage';
import { parseMessage } from 'utils/post-message';
type Props = RenderableProps<{
title: string;
@@ -0,0 +1,23 @@
.root {
display: inline-flex;
box-sizing: border-box;
border: 0;
margin: 0;
padding: 4px;
transition: transfrom 0.15s ease-out;
border-radius: 2px;
appearance: none;
&:hover {
transform: scale(1.06);
transition: transfrom 0.15s ease-in;
}
&:active {
transform: scale(1);
}
&:focus {
box-shadow: inset 0 0 0 2px rgba(var(--primary-color), 0.5);
}
}
@@ -0,0 +1,14 @@
import clsx from 'clsx';
import { h, RenderableProps, JSX } from 'preact';
import styles from './icon-button.module.css';
type Props = JSX.HTMLAttributes<HTMLButtonElement> & RenderableProps<{ className?: string }>;
export function IconButton({ title, children, className, ...props }: Props) {
return (
<button className={clsx('icon-button', className, styles.root)} {...props}>
{children}
</button>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { h, JSX } from 'preact';
type Props = Omit<JSX.SVGAttributes<SVGSVGElement>, 'size'> & {
size?: number | string;
};
export function CrossIcon({ size = 14, ...props }: Props) {
return (
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M2 2L12 12M12 2L2 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { h, JSX } from 'preact';
type Props = Omit<JSX.SVGAttributes<SVGSVGElement>, 'size'> & {
size?: number | string;
};
export function SignOutIcon({ size = 16, ...props }: Props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16" fill="none" {...props}>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M5.7 15H2.6A1.6 1.6 0 011 13.4V2.6A1.6 1.6 0 012.6 1h3M11.1 11.9L15 8l-3.9-3.9M15 8H5.7"
/>
</svg>
);
}
@@ -20,7 +20,6 @@ export function ListComments({ comments = [] }: Props) {
<Comment
intl={intl}
key={comment.id}
CommentForm={null}
data={comment}
level={0}
view="preview"
@@ -28,7 +27,6 @@ export function ListComments({ comments = [] }: Props) {
user={null}
theme="light"
isCommentsDisabled={false}
post_info={null}
/>
))}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { h } from 'preact';
import clsx from 'clsx';
type Props = {
className?: string;
};
export function Preloader({ className }: Props) {
return <div className={clsx('preloader', className)} aria-label="Loading..." />;
}
@@ -1,3 +0,0 @@
export * from './preloader';
// all styles were moved to iframe.html
@@ -1,10 +0,0 @@
import { h } from 'preact';
import b, { Mix } from 'bem-react-helper';
type Props = {
mix?: Mix;
};
export function Preloader({ mix }: Props) {
return <div className={b('preloader', { mix })} />;
}
+1
View File
@@ -0,0 +1 @@
export * from './profile';
@@ -0,0 +1,170 @@
.root {
display: flex;
justify-content: flex-end;
height: 100%;
width: 100%;
transform: translateX(100%);
transition: transform 0.5s ease-out;
@media (min-width: 448px) {
transform: translateX(448px);
}
}
.rootAppear {
transform: translateX(0);
}
.rootDisapear {
transition: transform 0.5s ease-in;
}
.sidebar {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
background: rgb(var(--primary-background-color));
max-width: 100%;
width: 100%;
height: 100%;
@media (min-width: 448px) {
width: 400px;
}
}
.closeButtonWrapper {
position: absolute;
z-index: 1;
right: 10px;
top: 18px;
box-sizing: border-box;
color: rgb(var(--secondary-text-color));
@media (min-width: 448px) {
position: initial;
width: 100%;
padding: 4px;
box-sizing: border-box;
color: rgb(var(--white-color));
text-align: right; /* Position close button at right */
}
}
.header {
display: flex;
padding: 16px;
flex-shrink: 0;
align-items: center;
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
padding: 0 16px 16px;
}
.content::-webkit-scrollbar {
width: 10px;
}
.content::-webkit-scrollbar-track {
background: unset;
border-radius: 3px;
}
.content::-webkit-scrollbar-thumb {
background-color: rgb(var(--primary-background-color));
border-radius: 5px;
border: 2px solid rgb(var(--white-color));
}
.footer {
position: sticky;
right: 0;
bottom: 0;
left: 0;
padding: 16px;
text-align: center;
}
.avatar {
flex-shrink: 0;
height: 32px;
width: 32px;
margin-right: 8px;
}
.info {
max-width: 100%;
margin: 0;
overflow: hidden;
line-height: 1;
}
.name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 700;
}
.id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--color13);
}
.signout {
margin-left: auto;
margin-right: 28px;
color: rgb(var(--secondary-text-color));
&:hover {
color: inherit;
}
@media (min-width: 448px) {
margin-right: 0;
}
}
.title {
position: sticky;
top: 0;
left: 0;
margin: 0 0 4px;
padding-top: 12px 0;
background-color: rgb(var(--primary-background-color));
z-index: 1;
&::after {
position: absolute;
top: 100%;
left: 0;
content: '';
width: 100%;
height: 20px;
background-image: linear-gradient(
0deg,
rgba(var(--primary-background-color), 0),
rgba(var(--primary-background-color), 1)
);
}
}
.preloader {
margin: 0 auto 18px;
color: var(--color13);
}
.emptyState {
margin: auto;
color: var(--color13);
}
@@ -0,0 +1,98 @@
import { h } from 'preact';
import '@testing-library/jest-dom';
import { waitFor } from '@testing-library/preact';
import { render } from 'tests/utils';
import * as api from 'common/api';
import * as pq from 'utils/parse-query';
import type { Comment, User } from 'common/types';
import { Profile } from './profile';
const userParamsStub = {
id: '1',
name: 'username',
picture: '/avatar.png',
};
const userStub: User = {
...userParamsStub,
ip: '',
picture: '/avatar.png',
admin: false,
block: false,
verified: false,
};
const commentStub: Comment = {
id: '1',
pid: '2',
text: 'comment content',
locator: {
site: '',
url: '',
},
score: 0,
vote: 0,
voted_ips: [],
time: '2021-04-02T14:52:39.985281605-05:00',
user: userStub,
};
const commentsStub = [commentStub, commentStub, commentStub];
describe('<Profile />', () => {
it('should render preloader', () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub }));
const { container } = render(<Profile />);
expect(container.querySelector('[aria-label="Loading..."]')).toBeInTheDocument();
});
it('should render without comments', async () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub }));
const getUserComments = jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments: [] }));
const { getByText } = render(<Profile />);
await waitFor(() => expect(getUserComments).toHaveBeenCalledWith('1'));
await waitFor(() => expect(getByText("Don't have comments yet")).toBeInTheDocument());
});
it('should render user with comments', async () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => userParamsStub);
jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments: commentsStub }));
const { getByText } = render(<Profile />);
await waitFor(() => expect(getByText('Recent comments')).toBeInTheDocument());
});
it('shoud render current user without comments', async () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub, current: '1' }));
const { getByText, getByTitle } = render(<Profile />);
expect(getByTitle('Sign Out')).toBeInTheDocument();
expect(getByText('Request my data removal')).toBeInTheDocument();
});
it('shoud render current user with comments', async () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub, current: '1' }));
jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments: commentsStub }));
const { getByText, getByTitle } = render(<Profile />);
expect(getByTitle('Sign Out')).toBeInTheDocument();
expect(getByText('Request my data removal')).toBeInTheDocument();
await waitFor(() => expect(getByText('My recent comments')).toBeInTheDocument());
});
it('should render user without footer', async () => {
jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub }));
jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments: commentsStub }));
const { container } = render(<Profile />);
expect(container.querySelector('profile-footer')).not.toBeInTheDocument();
});
});
+174
View File
@@ -0,0 +1,174 @@
import clsx from 'clsx';
import { h, Fragment } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useIntl, FormattedMessage } from 'react-intl';
import { getUserComments } from 'common/api';
import { parseQuery } from 'utils/parse-query';
import { requestDeletion } from 'utils/email';
import { setStyles } from 'utils/set-dom-props';
import { Avatar } from 'components/avatar';
import { postMessageToParent } from 'utils/post-message';
import { Comment } from 'components/comment';
import { Preloader } from 'components/preloader';
import { SignOutIcon } from 'components/icons/signout';
import { logout } from 'components/auth/auth.api';
import { Spinner } from 'components/spinner/spinner';
import { CrossIcon } from 'components/icons/cross';
import { IconButton } from 'components/icon-button/icon-button';
import { Button } from 'components/auth/components/button';
import { messages as authMessages } from 'components/auth/auth.messsages';
import type { Comment as CommentType, Theme } from 'common/types';
import styles from './profile.module.css';
async function signout() {
postMessageToParent({ profile: null, signout: true });
await logout();
}
// TODO: rewrite hide user logic and bring button to user profile
export function Profile() {
const intl = useIntl();
const rootRef = useRef<HTMLDivElement>(null);
const user = useMemo(() => parseQuery(), []);
const [error, setError] = useState(false);
const [comments, setComments] = useState<CommentType[] | null>(null);
const [isSigningOut, setSigningOut] = useState(false);
function handleClickClose() {
const rootElement = rootRef.current;
rootElement.classList.remove(styles.rootAppear);
rootElement.classList.add(styles.rootDisapear);
// No need to unsubscribe because iframe will be destroyed
rootElement.addEventListener('transitionend', () => {
postMessageToParent({ profile: null });
});
}
async function handleClickLogout() {
setSigningOut(true);
await signout();
setSigningOut?.(false);
}
async function handleClickRequestRemoveData() {
await requestDeletion();
await signout();
}
useEffect(() => {
getUserComments(user.id)
.then(({ comments }) => setComments(comments))
.catch(() => setError(true));
}, [user.id]);
useEffect(() => {
const styles = { height: '100%', padding: 0 };
setStyles(document.documentElement, styles);
setStyles(document.body, styles);
function handleKeydown(evt: KeyboardEvent): void {
if (evt.code !== 'Escape') {
return;
}
postMessageToParent({ profile: null });
}
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, []);
useEffect(() => {
rootRef.current.classList.add(styles.rootAppear);
}, []);
if (!user.id) {
return null;
}
const isCurrent = user.current === '1';
const commentsJSX = comments?.length ? (
<>
<h3 className={clsx('profile-title', styles.title)}>
{isCurrent ? (
<FormattedMessage id="user.my-comments" defaultMessage="My recent comments" />
) : (
<FormattedMessage key="user.recent-comments" id="user.recent-comments" defaultMessage="Recent comments" />
)}
</h3>
{comments.map((comment) => (
<Comment
key={comment.id}
user={null}
intl={intl}
data={comment}
level={0}
view="user"
isCommentsDisabled={false}
theme={(user.theme as Theme) || 'light'}
/>
))}
</>
) : (
<p className={clsx('profile-emptyState', styles.emptyState)}>
<FormattedMessage id="empty-state" defaultMessage="Don't have comments yet" />
</p>
);
return (
<div className={clsx('profile', styles.root)} ref={rootRef}>
{/* disable jsx-a11y/no-static-element-interactions and jsx-a11y/click-events-have-key-events */}
{/* that's fine because inside of the element we have button that will throw all events and provide all of the interactions */}
{/* eslint-disable-next-line */}
<div className={clsx('profile-close-button-wrapper', styles.closeButtonWrapper)} onClick={handleClickClose}>
<IconButton title={intl.formatMessage({ id: 'profile.close', defaultMessage: 'Close profile' })}>
<CrossIcon size="16" />
</IconButton>
</div>
<aside className={clsx('profile-sidebar', isCurrent && 'profile_current', styles.sidebar)}>
<header className={clsx('profile-header', styles.header)}>
<div className={clsx('profile-avatar', styles.avatar)}>
<Avatar data-testid="avatar" url={user.picture} />
</div>
<div className={clsx('profile-content', styles.info)}>
<div className={clsx('profile-title', styles.name)}>{user.name}</div>
<div className={clsx('profile-id', styles.id)}>{user.id}</div>
</div>
{isCurrent && (
<button
className={clsx('profile-signout', styles.signout)}
title={intl.formatMessage(authMessages.signout)}
onClick={handleClickLogout}
disabled={isSigningOut}
>
{isSigningOut ? <Spinner /> : <SignOutIcon />}
</button>
)}
</header>
<section className={clsx('profile-content', styles.content)}>
{error && (
<p className={clsx('profile-error', styles.error)}>
<FormattedMessage id="errors.0" defaultMessage="Something went wrong. Please try again a bit later." />
</p>
)}
{comments === null ? <Preloader /> : commentsJSX}
</section>
{isCurrent ? (
<footer className={clsx('profile-footer', styles.footer)}>
<Button kind="link" size="sm" onClick={handleClickRequestRemoveData}>
<FormattedMessage id="profile.request-to-delete-data" defaultMessage="Request my data removal" />
</Button>
</footer>
) : // TODO: implement hidding user comments
null}
</aside>
</div>
);
}
-1
View File
@@ -1,6 +1,5 @@
.root {
position: relative;
font-family: 'PT Sans', Helvetica, Arial, sans-serif;
&::selection {
background: var(--color42);
+24 -31
View File
@@ -1,19 +1,13 @@
import { h, Component, FunctionComponent, Fragment } from 'preact';
import { h, Component, Fragment } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { useSelector } from 'react-redux';
import b from 'bem-react-helper';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import clsx from 'clsx';
import type { Sorting } from 'common/types';
import 'styles/global.css';
import type { StoreState } from 'store';
import {
COMMENT_NODE_CLASSNAME_PREFIX,
MAX_SHOWN_ROOT_COMMENTS,
THEMES,
IS_MOBILE,
LS_EMAIL_KEY,
} from 'common/constants';
import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from 'common/constants';
import { maxShownComments, url } from 'common/settings';
import { StaticStore } from 'common/static-store';
@@ -25,11 +19,14 @@ import {
fetchBlockedUsers,
hideUser,
unhideUser,
signout,
} from 'store/user/actions';
import { fetchComments, updateSorting, addComment, updateComment, unsetCommentMode } from 'store/comments/actions';
import { setCommentsReadOnlyState } from 'store/post-info/actions';
import { setTheme } from 'store/theme/actions';
// TODO: make this button as default for all cases and replace current `components/Button`
import { Button } from 'components/auth/components/button';
import { Preloader } from 'components/preloader';
import { Settings } from 'components/settings';
import { AuthPanel } from 'components/auth-panel';
@@ -39,11 +36,10 @@ import { ConnectedComment as Comment } from 'components/comment/connected-commen
import { uploadImage, getPreview } from 'common/api';
import { isUserAnonymous } from 'utils/isUserAnonymous';
import { bindActions } from 'utils/actionBinder';
import { postMessageToParent, parseMessage } from 'utils/postMessage';
import { postMessageToParent, parseMessage } from 'utils/post-message';
import { useActions } from 'hooks/useAction';
import { setCollapse } from 'store/thread/actions';
import { logout } from 'components/auth/auth.api';
import { Button } from 'components/auth/components/button';
import { Sorting } from 'common/types';
import styles from './root.module.css';
@@ -85,6 +81,7 @@ const boundActions = bindActions({
updateComment,
setCollapse,
unsetCommentMode,
signout,
});
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions & { intl: IntlShape };
@@ -98,7 +95,7 @@ interface State {
const messages = defineMessages({
pinnedComments: {
id: `root.pinned-comments`,
id: 'root.pinned-comments',
defaultMessage: 'Pinned comments',
},
});
@@ -125,12 +122,12 @@ export class Root extends Component<Props, State> {
const userloading = this.props.fetchUser().finally(() => this.setState({ isUserLoading: false }));
Promise.all([userloading, this.props.fetchComments()]).finally(() => {
postMessageToParent({ height: document.body.offsetHeight });
setTimeout(this.checkUrlHash);
window.addEventListener('hashchange', this.checkUrlHash);
postMessageToParent({ height: document.body.offsetHeight });
});
window.addEventListener('message', this.onMessage.bind(this));
window.addEventListener('message', this.onMessage);
}
changeSort = async (sort: Sorting) => {
@@ -139,14 +136,6 @@ export class Root extends Component<Props, State> {
await this.props.updateSorting(sort);
};
logout = async () => {
await logout();
this.props.setUser();
this.props.unsetCommentMode();
localStorage.removeItem(LS_EMAIL_KEY);
await this.props.fetchComments();
};
checkUrlHash = (e: Event & { newURL: string }) => {
const hash = e ? `#${e.newURL.split('#')[1]}` : window.location.hash;
@@ -180,15 +169,19 @@ export class Root extends Component<Props, State> {
}
};
onMessage(event: MessageEvent) {
onMessage = (event: MessageEvent) => {
const data = parseMessage(event);
if (data.signout === true) {
this.props.signout(false);
}
if (!data.theme || !THEMES.includes(data.theme)) {
return;
}
this.props.setTheme(data.theme);
}
};
onBlockedUsersShow = async () => {
if (this.props.user && this.props.user.admin) {
@@ -220,7 +213,7 @@ export class Root extends Component<Props, State> {
render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) {
if (isUserLoading) {
return <Preloader mix="root__preloader" />;
return <Preloader className="root__preloader" />;
}
const isCommentsDisabled = props.info.read_only!;
@@ -234,7 +227,7 @@ export class Root extends Component<Props, State> {
onSortChange={this.changeSort}
isCommentsDisabled={isCommentsDisabled}
postInfo={this.props.info}
onSignOut={this.logout}
signout={this.props.signout}
onBlockedUsersShow={this.onBlockedUsersShow}
onBlockedUsersHide={this.onBlockedUsersHide}
onCommentsChangeReadOnlyMode={this.props.setCommentsReadOnlyState}
@@ -315,7 +308,7 @@ export class Root extends Component<Props, State> {
{props.isCommentsLoading && (
<div className="root__threads" role="list">
<Preloader mix="root__preloader" />
<Preloader className="root__preloader" />
</div>
)}
</>
@@ -333,10 +326,10 @@ const CopyrightLink = (title: string) => (
);
/** Root component connected to redux */
export const ConnectedRoot: FunctionComponent = () => {
export function ConnectedRoot() {
const intl = useIntl();
const props = useSelector(mapStateToProps);
const actions = useActions(boundActions);
const intl = useIntl();
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -362,4 +355,4 @@ export const ConnectedRoot: FunctionComponent = () => {
</p>
</div>
);
};
}
@@ -0,0 +1,8 @@
.root {
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;
}
@@ -0,0 +1,18 @@
import clsx from 'clsx';
import { h } from 'preact';
import { messages } from 'components/auth/auth.messsages';
import { useIntl } from 'react-intl';
import styles from './Spinner.module.css';
export function Spinner() {
const intl = useIntl();
return (
<div
className={clsx('spinner', styles.root)}
role="presentation"
aria-label={intl.formatMessage(messages.loading)}
/>
);
}
@@ -1,6 +0,0 @@
.user-info__avatar {
position: absolute;
top: 17px;
height: 30px;
width: 30px;
}
@@ -1,8 +0,0 @@
.user-info__id {
margin: 0 0 18px;
padding-left: 40px;
font-size: 14px;
font-weight: 400;
line-height: 18px;
color: var(--color13);
}
@@ -1,4 +0,0 @@
.user-info__preloader {
margin: 0 auto 18px;
color: var(--color13);
}
@@ -1,7 +0,0 @@
.user-info__title {
margin: 6px 0 0;
font-size: 18px;
font-weight: 700;
line-height: 20px;
padding-left: 40px;
}
@@ -1,8 +0,0 @@
import './user-info.css';
import './__avatar/user-info__avatar.css';
import './__id/user-info__id.css';
import './__preloader/user-info__preloader.css';
import './__title/user-info__title.css';
export { ConnectedUserInfo as UserInfo } from './user-info';
@@ -1,39 +0,0 @@
import { h, Fragment } from 'preact';
import { useIntl } from 'react-intl';
import { Comment as CommentType } from 'common/types';
import { Comment } from 'components/comment';
import { Preloader } from 'components/preloader';
type Props = {
comments: CommentType[];
isLoading: boolean;
};
export function LastCommentsList({ comments, isLoading }: Props) {
const intl = useIntl();
if (isLoading) {
return <Preloader mix="user-info__preloader" />;
}
return (
<>
{comments.map((comment) => (
<Comment
key={comment.id}
CommentForm={null}
intl={intl}
data={comment}
level={0}
view="user"
user={null}
isCommentsDisabled={false}
theme="light"
post_info={null}
/>
))}
</>
);
}
@@ -1,15 +0,0 @@
.user-info {
position: absolute;
top: 0;
right: 0;
bottom: 0;
overflow: inherit;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
width: 100%;
padding: 10px;
border: 1px solid var(--color41);
border-radius: 2px;
background: var(--color6);
}
@@ -1,104 +0,0 @@
import { h, JSX, Component, FunctionComponent } from 'preact';
import b from 'bem-react-helper';
import { useSelector } from 'react-redux';
import { useIntl, defineMessages, FormattedMessage, IntlShape } from 'react-intl';
import { StoreState } from 'store';
import { Comment } from 'common/types';
import { fetchInfo } from 'store/user-info/actions';
import { parseQuery } from 'utils/parseQuery';
import { bindActions } from 'utils/actionBinder';
import { postMessageToParent } from 'utils/postMessage';
import { useActions } from 'hooks/useAction';
import { Avatar } from 'components/avatar';
import { LastCommentsList } from './last-comments-list';
const boundActions = bindActions({ fetchInfo });
const messages = defineMessages({
unexpectedError: {
id: 'user-info.unexpected-error',
defaultMessage: 'Something went wrong',
},
});
const user = parseQuery();
type Props = {
comments: Comment[] | null;
} & typeof boundActions & { intl: IntlShape };
interface State {
isLoading: boolean;
error: string | null;
}
class UserInfo extends Component<Props, State> {
state = { isLoading: true, error: null };
componentWillMount(): void {
if (!this.props.comments && this.state.isLoading) {
this.props
.fetchInfo(user.id)
.then(() => {
this.setState({ isLoading: false });
})
.catch(() => {
this.setState({ isLoading: false, error: this.props.intl.formatMessage(messages.unexpectedError) });
});
}
document.addEventListener('keydown', UserInfo.onKeyDown);
}
componentWillUnmount(): void {
document.removeEventListener('keydown', UserInfo.onKeyDown);
}
render(): JSX.Element | null {
const { comments = [] } = this.props;
const { isLoading } = this.state;
// TODO: handle
if (!user) {
return null;
}
return (
<div className={b('root user-info', {})}>
<Avatar className="user-info__avatar" url={user.picture} />
<p className="user-info__title">
<FormattedMessage
id="user-info.last-comments"
defaultMessage="Last comments by {userName}"
values={{ userName: user.name }}
/>
</p>
<p className="user-info__id">{user.id}</p>
{!!comments && <LastCommentsList isLoading={isLoading} comments={comments} />}
</div>
);
}
/**
* Global on `keydown` handler which is set on component mount.
* Listens for user's `esc` key press
*/
static onKeyDown(e: KeyboardEvent): void {
// ESCAPE key pressed
if (e.keyCode === 27) {
postMessageToParent({ profile: null });
}
}
}
const commentsSelector = (state: StoreState) => state.userComments[user.id];
export const ConnectedUserInfo: FunctionComponent = () => {
const comments = useSelector(commentsSelector);
const actions = useActions(boundActions);
const intl = useIntl();
return <UserInfo comments={comments} {...actions} intl={intl} />;
};
+17
View File
@@ -0,0 +1,17 @@
export function Verified() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path
fill="#4fbbd6"
d="M5.823 14.822l-1.28.206a.824.824 0 0 1-.9-.52l-.463-1.212a.824.824 0 0 0-.476-.476l-1.212-.462a.824.824 0 0 1-.52-.9l.206-1.281a.824.824 0 0 0-.175-.65L.185 8.52a.824.824 0 0 1 0-1.04l.818-1.006a.824.824 0 0 0 .175-.65L.972 4.542a.824.824 0 0 1 .52-.9l1.212-.463a.824.824 0 0 0 .476-.476l.462-1.212a.824.824 0 0 1 .9-.52l1.281.206a.824.824 0 0 0 .65-.175L7.48.185a.824.824 0 0 1 1.04 0l1.006.818a.824.824 0 0 0 .65.175l1.281-.206a.824.824 0 0 1 .9.52l.463 1.212c.084.22.257.392.476.476l1.212.462c.365.14.582.515.52.9l-.206 1.281a.824.824 0 0 0 .175.65l.818 1.007a.824.824 0 0 1 0 1.04l-.818 1.006a.824.824 0 0 0-.175.65l.206 1.281a.824.824 0 0 1-.52.9l-1.212.463a.824.824 0 0 0-.476.476l-.462 1.212a.824.824 0 0 1-.9.52l-1.281-.206a.824.824 0 0 0-.65.175l-1.007.818a.824.824 0 0 1-1.04 0l-1.006-.818a.824.824 0 0 0-.65-.175z"
/>
<path
stroke="#fff"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M4.755 8.252L7 10.5l4.495-4.495"
/>
</svg>
);
}
-80
View File
@@ -1,80 +0,0 @@
:root {
--color0: #0f172a;
--color8: #262626;
--color22: #2d2d2c;
--color24: #313133;
--color7: #333;
--color23: #393734;
--color18: #383838;
--color19: #404040;
--color36: #575757;
--color34: #555;
--color37: #586069;
--color14: #6a6a6a;
--color10: #777;
--color13: #888;
--color11: #969696;
--color32: #a6a6a6;
--color1: #aaa;
--color35: #d1d5db;
--color20: #ddd;
--color46: rgba(27, 31, 35, 0.15);
--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);
--chroma-base: #586e75;
--chroma-c: #7d7d7d;
--chroma-01: #589000;
--chroma-02: #cb4b16;
--chroma-03: #268bd2;
--chroma-04: #2aa198;
--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);
}
+29 -234
View File
@@ -1,6 +1,8 @@
import type { UserInfo, Theme } from 'common/types';
import { BASE_URL, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants.config';
import { parseMessage, postMessageToIframe } from 'utils/postMessage';
import { NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants.config';
import { parseMessage, postMessageToIframe } from 'utils/post-message';
import { createIframe } from 'utils/create-iframe';
import type { Theme } from 'common/types';
import { closeProfile, openProfile } from 'profile';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -8,49 +10,6 @@ if (document.readyState === 'loading') {
init();
}
function removeDomNode(node: HTMLElement | null) {
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
}
function createFrame({
host,
query,
height,
margin = '-6px',
__colors__ = {},
}: {
host: string;
query: string;
height?: string;
margin?: string;
__colors__?: Record<string, string>;
}) {
const iframe = document.createElement('iframe');
iframe.src = `${host}/web/iframe.html?${query}`;
iframe.name = JSON.stringify({ __colors__ });
iframe.setAttribute('width', '100%');
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allowtransparency', 'true');
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('tabindex', '0');
iframe.setAttribute('title', 'Comments | Remark42');
iframe.setAttribute('horizontalscrolling', 'no');
iframe.setAttribute('verticalscrolling', 'no');
iframe.setAttribute(
'style',
`width: 1px !important; min-width: 100% !important; border: none !important; overflow: hidden !important; margin: ${margin};`
);
if (height) {
iframe.setAttribute('height', height);
}
return iframe;
}
function init() {
window.REMARK42 = window.REMARK42 || {};
window.REMARK42.createInstance = createInstance;
@@ -73,32 +32,20 @@ function createInstance(config: typeof window.remark_config) {
throw new Error('Remark42: Site ID is undefined.');
}
let initDataAnimationTimeout: number | null = null;
let titleObserver: MutationObserver | null = null;
config.url = (config.url || `${window.location.origin}${window.location.pathname}`).split('#')[0];
const query = Object.keys(config)
.filter((key) => key !== '__colors__')
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
config[key as keyof Omit<typeof window.remark_config, '__colors__'>] as string | number | boolean
)}`
)
.join('&');
const iframe =
(root.firstElementChild as HTMLIFrameElement) ||
createFrame({ host: BASE_URL, query, __colors__: config.__colors__ });
const iframe = (root.firstElementChild as HTMLIFrameElement) || createIframe(config);
root.appendChild(iframe);
window.addEventListener('message', receiveMessages);
window.addEventListener('hashchange', postHashToIframe);
window.addEventListener('message', handleReceiveMessage);
window.addEventListener('hashchange', handleHashChange);
document.addEventListener('click', postClickOutsideToIframe);
const titleElement = document.querySelector('title');
if (titleElement) {
titleObserver = new MutationObserver((mutations) => postTitleToIframe(mutations[0].target.textContent!));
titleObserver.observe(titleElement, {
@@ -108,198 +55,49 @@ function createInstance(config: typeof window.remark_config) {
});
}
const remarkRootId = 'remark-km423lmfdslkm34';
const userInfo: {
node: HTMLElement | null;
back: HTMLElement | null;
closeEl: HTMLElement | null;
iframe: HTMLIFrameElement | null;
style: HTMLStyleElement | null;
init: (user: UserInfo) => void;
close: () => void;
delay: number | null;
events: string[];
onAnimationClose: () => void;
onKeyDown: (e: KeyboardEvent) => void;
animationStop: () => void;
remove: () => void;
} = {
node: null,
back: null,
closeEl: null,
iframe: null,
style: null,
init(user) {
this.animationStop();
if (!this.style) {
this.style = document.createElement('style');
this.style.setAttribute('rel', 'stylesheet');
this.style.setAttribute('type', 'text/css');
this.style.innerHTML = `
#${remarkRootId}-node {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 400px;
transition: transform 0.4s ease-out;
max-width: 100%;
transform: translate(400px, 0);
}
#${remarkRootId}-node[data-animation] {
transform: translate(0, 0);
}
#${remarkRootId}-back {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
opacity: 0;
transition: opacity 0.4s ease-out;
}
#${remarkRootId}-back[data-animation] {
opacity: 1;
}
#${remarkRootId}-close {
top: 0px;
right: 400px;
position: absolute;
text-align: center;
font-size: 25px;
cursor: pointer;
color: white;
border-color: transparent;
border-width: 0;
padding: 0;
margin-right: 4px;
background-color: transparent;
}
@media all and (max-width: 430px) {
#${remarkRootId}-close {
right: 0px;
font-size: 20px;
color: black;
}
}
`;
}
if (!this.node) {
this.node = document.createElement('div');
this.node.id = `${remarkRootId}-node`;
}
if (!this.back) {
this.back = document.createElement('div');
this.back.id = `${remarkRootId}-back`;
this.back.onclick = () => this.close();
}
if (!this.closeEl) {
this.closeEl = document.createElement('button');
this.closeEl.id = `${remarkRootId}-close`;
this.closeEl.innerHTML = '&#10006;';
this.closeEl.onclick = () => this.close();
}
const queryUserInfo = `${query}&page=user-info&&id=${user.id}&name=${user.name}&picture=${user.picture || ''}`;
const iframe = createFrame({ host: BASE_URL, query: queryUserInfo, height: '100%', margin: '0' });
this.node.appendChild(iframe);
this.iframe = iframe;
this.node.appendChild(this.closeEl);
document.body.appendChild(this.style);
document.body.appendChild(this.back);
document.body.appendChild(this.node);
document.addEventListener('keydown', this.onKeyDown);
initDataAnimationTimeout = window.setTimeout(() => {
this.back!.setAttribute('data-animation', '');
this.node!.setAttribute('data-animation', '');
iframe.focus();
}, 400);
},
close() {
if (this.node) {
if (this.iframe) {
this.node.removeChild(this.iframe);
}
this.onAnimationClose();
this.node.removeAttribute('data-animation');
}
if (this.back) {
this.back.removeAttribute('data-animation');
}
document.removeEventListener('keydown', this.onKeyDown);
},
delay: null,
events: ['', 'webkit', 'moz', 'MS', 'o'].map((prefix) => (prefix ? `${prefix}TransitionEnd` : 'transitionend')),
onAnimationClose() {
const el = this.node!;
if (!this.node) {
return;
}
this.delay = window.setTimeout(this.animationStop, 1000);
this.events.forEach((event) => el.addEventListener(event, this.animationStop, false));
},
onKeyDown(e) {
// ESCAPE key pressed
if (e.keyCode === 27) {
userInfo.close();
}
},
animationStop() {
const t = userInfo;
if (!t.node) {
return;
}
if (t.delay) {
clearTimeout(t.delay);
t.delay = null;
}
t.events.forEach((event) => t.node!.removeEventListener(event, t.animationStop, false));
return t.remove();
},
remove() {
const t = userInfo;
removeDomNode(t.node);
removeDomNode(t.back);
removeDomNode(t.style);
},
};
function receiveMessages(event: MessageEvent): void {
function handleReceiveMessage(event: MessageEvent): void {
const data = parseMessage(event);
if (data.height) {
if (typeof data.height === 'number') {
iframe.style.height = `${data.height}px`;
}
if (data.scrollTo) {
if (typeof data.scrollTo === 'number') {
window.scrollTo(window.pageXOffset, data.scrollTo + iframe.getBoundingClientRect().top + window.pageYOffset);
}
if (typeof data.profile === 'object') {
if (data.profile === null) {
userInfo.close();
closeProfile();
} else {
userInfo.init(data.profile);
openProfile({ ...config, ...data.profile });
}
}
if (data.inited) {
if (data.signout === true) {
postMessageToIframe(iframe, { signout: true });
}
if (data.inited === true) {
postHashToIframe();
postTitleToIframe(document.title);
}
}
function postHashToIframe(evt?: Event & { newURL: string }) {
const hash = evt ? `#${evt.newURL.split('#')[1]}` : window.location.hash;
function postHashToIframe(hash = window.location.hash) {
if (!hash.startsWith(`#${COMMENT_NODE_CLASSNAME_PREFIX}`)) {
return;
}
evt?.preventDefault();
postMessageToIframe(iframe, { hash });
}
function handleHashChange(evt: HashChangeEvent) {
const url = new URL(evt.newURL);
postHashToIframe(url.hash);
}
function postTitleToIframe(title: string) {
postMessageToIframe(iframe, { title });
}
@@ -312,16 +110,13 @@ function createInstance(config: typeof window.remark_config) {
}
function changeTheme(theme: Theme) {
window.remark_config.theme = theme;
postMessageToIframe(iframe, { theme });
}
function destroy() {
if (initDataAnimationTimeout) {
clearTimeout(initDataAnimationTimeout);
}
window.removeEventListener('message', receiveMessages);
window.removeEventListener('hashchange', postHashToIframe);
window.removeEventListener('message', handleReceiveMessage);
window.removeEventListener('hashchange', handleHashChange);
document.removeEventListener('click', postClickOutsideToIframe);
if (titleObserver) {
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Загрузка...",
"auth.oauth-button": "Увайсці праз {provider}",
"auth.oauth-source": "Скарыстацца сацыяльнай сеткай",
"auth.open-profile": "Open My Profile",
"auth.or": "або",
"auth.signin": "Увайсці",
"auth.signout": "Выйсці?",
"auth.submit": "Адправіць",
"auth.symbols-restriction": "імя карыстальніка мусіць пачынацца з літары і ўтрымоўваць толькі лацінскія літары, лічбы, знакі падкрэслівання і прабелы(?)",
"auth.user-not-found": "Карыстальнік не знойдзены",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Уключыць каментары",
"authPanel.enable-cookies": "Дазвольце кукі, каб увайсці і каментаваць",
"authPanel.hide-settings": "Схаваць налады",
"authPanel.logged-as": "Вы ўвайшлі як",
"authPanel.logout": "Выйсці?",
"authPanel.new-page": "новая старонка",
"authPanel.read-only": "Толькі для чытання",
"authPanel.request-to-delete-data": "Запытаць выдаленне маіх даных",
"authPanel.show-settings": "Паказаць налады",
"blockingDuration.day": "На дзень",
"blockingDuration.month": "На месяц",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Старыя",
"commentsSort.recently-updated": "Нядаўна абноўленыя",
"commentsSort.worst": "Горшыя",
"empty-state": "Don't have comments yet",
"errors.0": "Нешта пайшло не так. Калі ласка, паспрабуйце яшчэ раз крыху пазней.",
"errors.1": "Каментар не знойдзены. Калі ласка, абнавіце старонку і паспрабуйце зноў.",
"errors.10": "Час рэдагавання каментара сышоў.",
@@ -94,6 +94,7 @@
"errors.18": "Запытаны файл не знойдзены.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Не атрымалася апрацаваць адказ сервера.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Вы не маеце дазволу для гэтага дзеяння.",
"errors.4": "Няправільна адфарматаваны каментар.",
"errors.5": "Каментар не знойдзены. Калі ласка, абнавіце старонку і паспрабуйце яшчэ раз.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Не аўтарызаваны.",
"errors.to-many-request": "Зашмат запытаў.",
"errors.unexpected-error": "Нешта пайшло не так.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Замацаваныя каментары",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Паказаць яшчэ",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Нумараваны спіс",
"toolbar.quote": "Цытата",
"toolbar.unordered-list": "Ненумараваны спіс",
"user-info.last-comments": "Апошнія каментары {userName}",
"user-info.unexpected-error": "Нешта пайшло не так",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Ананімныя карыстальнікі не могуць галасаваць",
"vote.deleted": "Нельга галасаваць за выдалены каментар",
"vote.guest": "Увайдзіце ў сістэму, каб галасаваць",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Зареждане...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "или",
"auth.signin": "Вход",
"auth.signout": "Изход",
"auth.submit": "Изпрати",
"auth.symbols-restriction": "Потребителското име трябва да започва с буква и да бъде само от латински букви, цифри, подчертавки или разтояния",
"auth.user-not-found": "Не бе намерен потребител",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Разреши коментарите",
"authPanel.enable-cookies": "Разреши бисквитките за вход и коментар",
"authPanel.hide-settings": "Скрий настройките",
"authPanel.logged-as": "Вие влязохте като",
"authPanel.logout": "Изход?",
"authPanel.new-page": "нова страница",
"authPanel.read-only": "Само за четене",
"authPanel.request-to-delete-data": "Заявка за премахване на моите данни",
"authPanel.show-settings": "Покажи настройките",
"blockingDuration.day": "За един ден",
"blockingDuration.month": "За един месец",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Най-стар",
"commentsSort.recently-updated": "Най-скоро променен",
"commentsSort.worst": "Най-лошия",
"empty-state": "Don't have comments yet",
"errors.0": "Има някакъв проблем. Моля, опитайте отново по-късно.",
"errors.1": "Коментара не бе намерен. Моля презаредете страницата и опитайте пак.",
"errors.10": "Твърде късно е за редактиране на коментара.",
@@ -94,6 +94,7 @@
"errors.18": "Файла не бе намерен.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Неуспешно премахване на входящата заявка.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Нямате привилегия за тази операция.",
"errors.4": "Невалидни данни на коментара.",
"errors.5": "Коментара не бе намерен. Моля презаредете странцата и опитайте пак.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Неоторизиран.",
"errors.to-many-request": "Достигнахте лимита на заявки.",
"errors.unexpected-error": "Има някакъв проблем.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Фиксирани коментари",
"root.powered-by": "Използва <a>Remark42</a>",
"root.show-more": "Покажи още",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Добави номериран списък",
"toolbar.quote": "Добави цитат",
"toolbar.unordered-list": "Добави обозначен списък",
"user-info.last-comments": "Последни коментари от {userName}",
"user-info.unexpected-error": "Има някакъв проблем",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Анонимни потребители не могат да гласуват",
"vote.deleted": "Не може да гласъвате за изтрит коментарт",
"vote.guest": "Влезте за да гласувате",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Carregando...",
"auth.oauth-button": "Entrar com {provider}",
"auth.oauth-source": "Use redes sociais",
"auth.open-profile": "Open My Profile",
"auth.or": "ou",
"auth.signin": "Entrar",
"auth.signout": "Fazer logout",
"auth.submit": "Enviar",
"auth.symbols-restriction": "O nome de usuário deve conter apenas letras, números, sublinhados ou espaços",
"auth.user-not-found": "Nenhum usuário encontrado",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Habilitar comentários",
"authPanel.enable-cookies": "Permita cookies para fazer login e comentar",
"authPanel.hide-settings": "Ocultar configurações",
"authPanel.logged-as": "Você fez login como",
"authPanel.logout": "Fazer logout?",
"authPanel.new-page": "nova página",
"authPanel.read-only": "Somente leitura",
"authPanel.request-to-delete-data": "Solicitar remoção dos meus dados",
"authPanel.show-settings": "Mostrar configurações",
"blockingDuration.day": "Por um dia",
"blockingDuration.month": "Por um mês",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Mais antigo",
"commentsSort.recently-updated": "Atualizado recentemente",
"commentsSort.worst": "Pior",
"empty-state": "Don't have comments yet",
"errors.0": "Ocorreu um erro. Tente novamente mais tarde.",
"errors.1": "O comentário não pode ser encontrado. Atualize a página e tente novamente.",
"errors.10": "É muito tarde para editar o comentário.",
@@ -94,6 +94,7 @@
"errors.18": "O arquivo solicitado não foi encontrado.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Falha ao fazer unmarshalling da solicitação de entrada.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Você não tem permissão para esta operação.",
"errors.4": "Dados de comentário inválidos.",
"errors.5": "O comentário não pode ser encontrado. Atualize a página e tente novamente.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Não autorizado.",
"errors.to-many-request": "Você atingiu o limite máximo de solicitações.",
"errors.unexpected-error": "Algo deu errado.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Comentários afixados",
"root.powered-by": "Com a tecnologia <a>Remark42</a>",
"root.show-more": "Mostrar mais",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Adicionar uma lista numerada",
"toolbar.quote": "Inserir citação",
"toolbar.unordered-list": "Adicionar lista com marcadores",
"user-info.last-comments": "Últimos comentários de {userName}",
"user-info.unexpected-error": "Algo deu errado",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Usuários anônimos não podem votar",
"vote.deleted": "Não posso votar em um comentário excluído",
"vote.guest": "Faça login para votar",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Wird geladen ...",
"auth.oauth-button": "Mit {provider} anmelden",
"auth.oauth-source": "Soziales Netzwerk verwenden",
"auth.open-profile": "Open My Profile",
"auth.or": "oder",
"auth.signin": "Einloggen",
"auth.signout": "Abmelden",
"auth.submit": "Absenden",
"auth.symbols-restriction": "Der Benutzername darf nur Buchstaben, Zahlen, Unterstriche oder Leerzeichen enthalten",
"auth.user-not-found": "Benutzer konnte nicht gefunden werden",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Kommentarfunktion aktivieren",
"authPanel.enable-cookies": "Cookies für die Anmeldung und das Kommentieren zulassen",
"authPanel.hide-settings": "Einstellungen ausblenden",
"authPanel.logged-as": "Sie haben sich angemeldet als",
"authPanel.logout": "Abmelden?",
"authPanel.new-page": "neue Seite",
"authPanel.read-only": "Schreibgeschützt",
"authPanel.request-to-delete-data": "Löschung meiner Daten anfordern",
"authPanel.show-settings": "Einstellungen anzeigen",
"blockingDuration.day": "Für einen Tag",
"blockingDuration.month": "Für einen Monat",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Älteste",
"commentsSort.recently-updated": "Kürzlich aktualisierte",
"commentsSort.worst": "Schlechteste",
"empty-state": "Don't have comments yet",
"errors.0": "Leider ist etwas schiefgegangen. Bitte versuchen Sie es später erneut.",
"errors.1": "Kommentar nicht gefunden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
"errors.10": "Das Zeitfenster für das Bearbeiten des Kommentars ist verstrichen.",
@@ -94,6 +94,7 @@
"errors.18": "Die angeforderte Datei wurde nicht gefunden.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Die eingehende Anfrage konnte nicht verarbeitet werden.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Für diesen Vorgang haben Sie keine ausreichende Berechtigung.",
"errors.4": "Ungültige Kommentardaten.",
"errors.5": "Kommentar nicht gefunden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Nicht autorisiert.",
"errors.to-many-request": "Sie haben das maximale Anfragelimit erreicht.",
"errors.unexpected-error": "Leider ist etwas schiefgegangen.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Angeheftete Kommentare",
"root.powered-by": "Unterstützt von <a>Remark42</a>",
"root.show-more": "Mehr anzeigen",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Nummerierte Liste einfügen",
"toolbar.quote": "Zitat einfügen",
"toolbar.unordered-list": "Aufzählungsliste einfügen",
"user-info.last-comments": "Kommentare von Benutzer {userName}",
"user-info.unexpected-error": "Leider ist etwas schiefgegangen.",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Anonyme Benutzer können leider nicht abstimmen",
"vote.deleted": "Sie können nicht für einen gelöschten Kommentar abstimmen",
"vote.guest": "Melden Sie sich an, um abzustimmen",
+9 -6
View File
@@ -4,8 +4,10 @@
"auth.loading": "Loading...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "or",
"auth.signin": "Sign In",
"auth.signout": "Sign Out",
"auth.submit": "Submit",
"auth.symbols-restriction": "Username must contain only letters, numbers, underscores or spaces",
"auth.user-not-found": "No user was found",
@@ -15,11 +17,8 @@
"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.logout": "Logout?",
"authPanel.new-page": "new page",
"authPanel.read-only": "Read-only",
"authPanel.request-to-delete-data": "Request my data removal",
"authPanel.show-settings": "Show settings",
"blockingDuration.day": "For a day",
"blockingDuration.month": "For a month",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Oldest",
"commentsSort.recently-updated": "Recently updated",
"commentsSort.worst": "Worst",
"empty-state": "Don't have comments yet",
"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.",
@@ -94,18 +94,21 @@
"errors.18": "Requested file cannot be found.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Failed to unmarshal incoming request.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "You don't have permission for this operation.",
"errors.4": "Invalid comment data.",
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
"errors.6": "Site cannot be found. Please refresh the page and try again.",
"errors.7": "User has been blocked.",
"errors.8": "User has been blocked.",
"errors.8": "Can't post comments on this page. Comments are read only.",
"errors.9": "Comment changing failed. Please try again a bit later.",
"errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later",
"errors.forbidden": "Forbidden.",
"errors.not-authorized": "Not authorized.",
"errors.to-many-request": "You have reached maximum request limit.",
"errors.unexpected-error": "Something went wrong.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Pinned comments",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Show more",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Add a numbered list",
"toolbar.quote": "Insert a quote",
"toolbar.unordered-list": "Add a bulleted list",
"user-info.last-comments": "Last comments by {userName}",
"user-info.unexpected-error": "Something went wrong",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Anonymous users can't vote",
"vote.deleted": "Can't vote for deleted comment",
"vote.guest": "Sign in to vote",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Cargando...",
"auth.oauth-button": "Inicie sesión con {provider}",
"auth.oauth-source": "Acceda a través de sus redes sociales",
"auth.open-profile": "Open My Profile",
"auth.or": "o",
"auth.signin": "Acceder",
"auth.signout": "¿Salir?",
"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",
@@ -15,11 +17,8 @@
"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.logout": "¿Salir?",
"authPanel.new-page": "nueva página",
"authPanel.read-only": "Solo lectura",
"authPanel.request-to-delete-data": "Solicitar la eliminación de mis datos",
"authPanel.show-settings": "Mostrar opciones",
"blockingDuration.day": "Por un día",
"blockingDuration.month": "Por un mes",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Más antiguo",
"commentsSort.recently-updated": "Actualizado más recientemente",
"commentsSort.worst": "Peor",
"empty-state": "Don't have comments yet",
"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.",
@@ -94,6 +94,7 @@
"errors.18": "No se ha encontrado el archivo solicitado.",
"errors.19": "Comment contains restricted words.",
"errors.2": "No se ha podido deserializar la petición entrante.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "No tienes permisos para esta operación.",
"errors.4": "Datos de comentario inválidos.",
"errors.5": "El comentario no se ha encontrado. Por favor refresca la página y vuelve a intentar.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "No autorizado.",
"errors.to-many-request": "Has llegado al límite de peticiones.",
"errors.unexpected-error": "Algo salió mal.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Comentarios anclados",
"root.powered-by": "Con la tecnología de <a>Remark42</a>",
"root.show-more": "Mostrar más",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Agrega una lista numerada",
"toolbar.quote": "Inserta una cita",
"toolbar.unordered-list": "Agrega una lista sin numerar",
"user-info.last-comments": "Últimos comentarios de {userName}",
"user-info.unexpected-error": "Algo salió mal",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Los usuarios anónimos no puede votar",
"vote.deleted": "No se puede votar un comentario eliminado",
"vote.guest": "Accede para votar",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Ladataan...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "tai",
"auth.signin": "Kirjaudu sisään",
"auth.signout": "Kirjaudu ulos",
"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",
@@ -15,11 +17,8 @@
"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.logout": "Kirjaudu ulos?",
"authPanel.new-page": "uusi sivu",
"authPanel.read-only": "Vain luku",
"authPanel.request-to-delete-data": "Pyydä tietojen poistamista",
"authPanel.show-settings": "Näytä asetukset",
"blockingDuration.day": "Päiväksi",
"blockingDuration.month": "Kuukaudeksi",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Vanhin",
"commentsSort.recently-updated": "Äskettäin päivitetty",
"commentsSort.worst": "Huonoin",
"empty-state": "Don't have comments yet",
"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.",
@@ -94,6 +94,7 @@
"errors.18": "Pyydettyä tiedostoa ei löydy.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Failed to unmarshal incoming request.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Sinulla ei ole lupaa tähän operaatioon.",
"errors.4": "Virheellinen kommentti.",
"errors.5": "Kommenttia ei löydy. Päivitä sivu ja yritä uudelleen.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Ei sallittu.",
"errors.to-many-request": "Liian monta pyyntöä.",
"errors.unexpected-error": "Jotain meni pieleen.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Kiinnitetyt kommentit",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Näytä lisää",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Järjestetty luettelo",
"toolbar.quote": "Lainaus",
"toolbar.unordered-list": "Järjestämätön luettelo",
"user-info.last-comments": "Uusimmat kommentit käyttäjältä {userName}",
"user-info.unexpected-error": "Jotain meni pieleen",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Anonyymit käyttäjät eivät voi äänestää",
"vote.deleted": "Poistettua kommenttia ei voi äänestää",
"vote.guest": "Kirjaudu sisään äänestääksesi",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Chargement...",
"auth.oauth-button": "Se connecter avec {provider}",
"auth.oauth-source": "Utiliser les réseaux sociaux",
"auth.open-profile": "Open My Profile",
"auth.or": "ou",
"auth.signin": "Se connecter",
"auth.signout": "Se déconnecter",
"auth.submit": "Valider",
"auth.symbols-restriction": "Le nom d'utilisateur ne doit contenir que des lettres, nombres, tirets bas et espaces.",
"auth.user-not-found": "Aucun utilisateur n'a été trouvé",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Permettre les commentaires",
"authPanel.enable-cookies": "Activez les cookies pour vous connecter et commenter",
"authPanel.hide-settings": "Cacher les paramètres",
"authPanel.logged-as": "Vous êtes connecté en tant que",
"authPanel.logout": "Se déconnecter ?",
"authPanel.new-page": "nouvelle page",
"authPanel.read-only": "Lecture seule",
"authPanel.request-to-delete-data": "Demander la suppression de mes données",
"authPanel.show-settings": "Afficher les paramètres",
"blockingDuration.day": "Pour un jour",
"blockingDuration.month": "Pour un mois",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Le plus ancien",
"commentsSort.recently-updated": "Mis à jour récemment",
"commentsSort.worst": "Le pire",
"empty-state": "Don't have comments yet",
"errors.0": "Une erreur s'est produite. Veuillez réessayer un peu plus tard.",
"errors.1": "Commentaire introuvable. Rafraichissez la page et réessayez.",
"errors.10": "Il n'est plus possible de modifier ce commentaire.",
@@ -94,6 +94,7 @@
"errors.18": "Le fichier demandé est introuvable.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Échec du traitement de la requête entrante.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Vous n'avez pas l'autorisation d'effectuer cette opération.",
"errors.4": "Données de commentaire non valides.",
"errors.5": "Commentaire introuvable. Rafraichissez la page et réessayez.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Non autorisé.",
"errors.to-many-request": "Vous avez atteint la limite maximale de requêtes.",
"errors.unexpected-error": "Un problème est survenu.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Commentaires épinglés",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Afficher plus",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Ajouter une liste numérotée",
"toolbar.quote": "Insérer une citation",
"toolbar.unordered-list": "Ajouter une liste à puces",
"user-info.last-comments": "Derniers commentaires par {userName}",
"user-info.unexpected-error": "Un problème est survenu",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Les utilisateurs anonymes ne peuvent pas voter",
"vote.deleted": "Vous ne pouvez pas voter pour un commentaire supprimé",
"vote.guest": "Connectez-vous pour voter",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "読み込んでいます…",
"auth.oauth-button": "{provider} でサインイン",
"auth.oauth-source": "ソーシャルネットワークを使用",
"auth.open-profile": "Open My Profile",
"auth.or": "または",
"auth.signin": "サインイン",
"auth.signout": "ログアウトしますか",
"auth.submit": "送信",
"auth.symbols-restriction": "ユーザー名には英数字、アンダースコア、またはスペースのみを指定してください",
"auth.user-not-found": "ユーザーが見つかりませんでした",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "コメントの有効化",
"authPanel.enable-cookies": "Cookie を許可してログインとコメントを有効にしてください",
"authPanel.hide-settings": "設定を非表示",
"authPanel.logged-as": "次でログイン中:",
"authPanel.logout": "ログアウトしますか?",
"authPanel.new-page": "新規ページ",
"authPanel.read-only": "読み取り専用",
"authPanel.request-to-delete-data": "データ除去の依頼",
"authPanel.show-settings": "設定を表示",
"blockingDuration.day": "1 日",
"blockingDuration.month": "1 カ月",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "古い順",
"commentsSort.recently-updated": "更新が最も新しい順",
"commentsSort.worst": "最悪",
"empty-state": "Don't have comments yet",
"errors.0": "不明なエラーが発生しました。しばらくしてからもう一度お試しください。",
"errors.1": "コメントが見つかりません。ページを再読み込みしてからもう一度お試しください。",
"errors.10": "コメントを編集するには遅すぎます。",
@@ -94,6 +94,7 @@
"errors.18": "リクエストされたファイルが見つかりません。",
"errors.19": "Comment contains restricted words.",
"errors.2": "受信したリクエストを処理できません",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "この操作を実行する権限がありません。",
"errors.4": "コメントデータが無効です。",
"errors.5": "コメントが見つかりません。ページを再読み込みしてからもう一度お試しください。",
@@ -106,6 +107,8 @@
"errors.not-authorized": "認証されていません。",
"errors.to-many-request": "最大リクエスト回数に達しました。",
"errors.unexpected-error": "不明なエラーが発生しました.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "ピン留めしたコメント",
"root.powered-by": "提供元: <a>Remark42</a>",
"root.show-more": "その他を表示",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "番号付きリストの追加",
"toolbar.quote": "引用符の挿入",
"toolbar.unordered-list": "記号付きリストの追加",
"user-info.last-comments": "{userName}の最新コメント",
"user-info.unexpected-error": "不明なエラーが発生しました",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "匿名ユーザーは投稿できません",
"vote.deleted": "削除済みコメントには投票できません",
"vote.guest": "投票するにはサインインしてください",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "로드 중…",
"auth.oauth-button": "{provider}(으)로 로그인",
"auth.oauth-source": "소셜 네트워크 사용",
"auth.open-profile": "Open My Profile",
"auth.or": "또는",
"auth.signin": "로그인",
"auth.signout": "로그아웃하시겠어요",
"auth.submit": "확인",
"auth.symbols-restriction": "사용자 이름에는 문자, 숫자, 밑줄 또는 공백만 포함되어야 합니다",
"auth.user-not-found": "사용자를 찾을 수 없습니다",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "댓글 활성화",
"authPanel.enable-cookies": "로그인 및 댓글 작업을 할 수 있도록 쿠키 허용",
"authPanel.hide-settings": "설정 숨기기",
"authPanel.logged-as": "다음으로 로그인되어 있습니다",
"authPanel.logout": "로그아웃하시겠어요?",
"authPanel.new-page": "새 페이지",
"authPanel.read-only": "읽기 전용",
"authPanel.request-to-delete-data": "내 데이터 제거 요청",
"authPanel.show-settings": "설정 표시",
"blockingDuration.day": "1일",
"blockingDuration.month": "1개월",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "오래된 순",
"commentsSort.recently-updated": "최신 업데이트 순",
"commentsSort.worst": "최저",
"empty-state": "Don't have comments yet",
"errors.0": "문제가 발생했습니다. 잠시 후 다시 시도해 주세요.",
"errors.1": "댓글을 찾을 수 없습니다. 페이지를 새로고침하여 다시 시도하세요.",
"errors.10": "댓글을 추가하기에 너무 늦었습니다.",
@@ -94,6 +94,7 @@
"errors.18": "요청한 파일을 찾을 수 없습니다.",
"errors.19": "Comment contains restricted words.",
"errors.2": "수신 요청 처리가 실패했습니다.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "이 작업을 수행할 수 있는 권한이 없습니다.",
"errors.4": "댓글 데이터가 유효하지 않습니다.",
"errors.5": "댓글을 찾을 수 없습니다. 페이지를 새로고침하여 다시 시도하세요.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "권한이 없습니다.",
"errors.to-many-request": "최대 요청 한도에 도달했습니다.",
"errors.unexpected-error": "문제가 발생했습니다.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "고정된 댓글",
"root.powered-by": "<a>Remark42</a> 제공",
"root.show-more": "더보기",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "숫자 지정된 목록 추가",
"toolbar.quote": "인용구 삽입",
"toolbar.unordered-list": "글머리 기호가 지정된 목록 추가",
"user-info.last-comments": "{userName} 님의 마지막 댓글",
"user-info.unexpected-error": "문제가 발생했습니다",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "익명의 사용자는 투표할 수 없습니다",
"vote.deleted": "삭제된 댓글에는 투표할 수 없습니다",
"vote.guest": "투표하려면 로그인하세요",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Ładuję...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "lub",
"auth.signin": "Zaloguj się",
"auth.signout": "Wyloguj",
"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",
@@ -15,11 +17,8 @@
"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.logout": "Wyloguj?",
"authPanel.new-page": "nowa strona",
"authPanel.read-only": "Tylko do odczytu",
"authPanel.request-to-delete-data": "Zażądaj usunięcia swoich danych",
"authPanel.show-settings": "Pokaż ustawienia",
"blockingDuration.day": "Na dzień",
"blockingDuration.month": "Na miesiąc",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Najstarsze",
"commentsSort.recently-updated": "Ostatnio aktualizowane",
"commentsSort.worst": "Najgorsze",
"empty-state": "Don't have comments yet",
"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.",
@@ -94,6 +94,7 @@
"errors.18": "Żądany plik nie został znaleziony.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Nie udało sie sparsować przychodzącego zapytania do struktury danych.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Nie masz wystarczających uprawnień by wykonać te operację.",
"errors.4": "Niepoprawne dane komentarza.",
"errors.5": "Komentarz nie może zostać odnaleziony. Odśwież stronę i spróbuj ponownie.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Nie jesteś upoważniony.",
"errors.to-many-request": "Przekroczyłeś maksymalny limit zapytań.",
"errors.unexpected-error": "Coś poszło nie tak.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Przypięte komentarze",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Pokaż więcej",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Dodaj numerowaną listę",
"toolbar.quote": "Dodaj cytat",
"toolbar.unordered-list": "Dodaj listę punktowaną",
"user-info.last-comments": "Ostatnie komentarze {userName}",
"user-info.unexpected-error": "Coś poszło nie tak",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Anonimowi użytkownicy nie mają prawa głosu",
"vote.deleted": "Nie można głosować na usunięte komentarze",
"vote.guest": "Zaloguj się żeby głosować",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Загрузка...",
"auth.oauth-button": "Войти через {provider}",
"auth.oauth-source": "Использовать социальные сети",
"auth.open-profile": "Open My Profile",
"auth.or": "или",
"auth.signin": "Войти",
"auth.signout": "Выйти",
"auth.submit": "Отправить",
"auth.symbols-restriction": "Имя пользователя должно начинаться с буквы и содержать только латинские буквы, цифры, знаки подчеркивания и пробелы",
"auth.user-not-found": "Пользователь не найден",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Включить комментарии",
"authPanel.enable-cookies": "Разрешите использование файлов cookie, чтобы авторизоваться и комментировать",
"authPanel.hide-settings": "Скрыть настройки",
"authPanel.logged-as": "Вы вошли как",
"authPanel.logout": "Выйти?",
"authPanel.new-page": "новой странице",
"authPanel.read-only": "Только для чтения",
"authPanel.request-to-delete-data": "Запросить удаление своих данных",
"authPanel.show-settings": "Показать настройки",
"blockingDuration.day": "На день",
"blockingDuration.month": "На месяц",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Самые старые",
"commentsSort.recently-updated": "Обновленные недавно",
"commentsSort.worst": "Худшие",
"empty-state": "Don't have comments yet",
"errors.0": "Что-то пошло не так. Попробуйте еще раз позже.",
"errors.1": "Комментарий не найден. Обновите страницу и попробуйте еще раз.",
"errors.10": "Время редактирования комментария истекло.",
@@ -94,6 +94,7 @@
"errors.18": "Не удалось найти запрошенный файл.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Не удалось обработать ответ от сервера.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "У вас недостаточно прав для выполнения этого действия.",
"errors.4": "Комментарий содержит недопустимые данные.",
"errors.5": "Комментарий не найден. Обновите страницу и попробуйте еще раз.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Не авторизован.",
"errors.to-many-request": "Достигнуто максимальное число запросов.",
"errors.unexpected-error": "Что-то пошло не так.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Закрепленные комментарии",
"root.powered-by": "Работает на базе <a>Remark42</a>",
"root.show-more": "Показать еще",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Добавить нумерованный список",
"toolbar.quote": "Вставить цитату",
"toolbar.unordered-list": "Добавить маркированный список",
"user-info.last-comments": "Последние комментарии {userName}",
"user-info.unexpected-error": "Что-то пошло не так",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Анонимные пользователи не могут голосовать",
"vote.deleted": "Нельзя голосовать за удаленный комментарий",
"vote.guest": "Войдите, чтобы проголосовать",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Yükleniyor...",
"auth.oauth-button": "{provider} ile Oturum Aç",
"auth.oauth-source": "Sosyal Ağ Kullan",
"auth.open-profile": "Open My Profile",
"auth.or": "veya",
"auth.signin": "Giriş yap",
"auth.signout": "Çıkış 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ı",
@@ -15,11 +17,8 @@
"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.logout": "Çıkış yap?",
"authPanel.new-page": "yeni sayfa",
"authPanel.read-only": "Yazmaya kapalı",
"authPanel.request-to-delete-data": "Bilgilerimi sil",
"authPanel.show-settings": "Ayarları göster",
"blockingDuration.day": "bir günlük",
"blockingDuration.month": "bir aylık",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "En eskiler",
"commentsSort.recently-updated": "En son güncellenen",
"commentsSort.worst": "En kötüler",
"empty-state": "Don't have comments yet",
"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ç.",
@@ -94,6 +94,7 @@
"errors.18": "İstenilen dosya bulunamadı.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Gelen talep işlenemedi.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Bu işlemi yapmak için yetkiniz yok.",
"errors.4": "Yorum verisi geçersiz.",
"errors.5": "Yorum bulunamadı. Lütfen sayfayı yenileyip tekrar deneyin.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Yetki yetersiz.",
"errors.to-many-request": "Maksimum talep limitine ulaştınız.",
"errors.unexpected-error": "Bir hata oluştu.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Sabitlenmiş yorumlar",
"root.powered-by": "<a>Remark42</a> ile üretildi",
"root.show-more": "Daha fazla göster",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Sıralı liste ekle",
"toolbar.quote": "Alıntı ekle",
"toolbar.unordered-list": "Liste ekle",
"user-info.last-comments": "{userName} tarafından son yorumlar",
"user-info.unexpected-error": "Bir hata oluştu",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Anonim kullanıcılar oy kullanamaz",
"vote.deleted": "Silinmiş yorum oylanamaz",
"vote.guest": "Oy vermek için giriş yapın",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "Завантаження...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "або",
"auth.signin": "Увійти",
"auth.signout": "Вийти",
"auth.submit": "Відправити",
"auth.symbols-restriction": "Ім’я користувача повинно починатися з літери і містити тільки латинські букви,цифри,знаки підкреслення і прогалини",
"auth.user-not-found": "Користувач не знайдений",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "Увімкнути коментари",
"authPanel.enable-cookies": "Дозвольте Cookies",
"authPanel.hide-settings": "Заховати налаштування",
"authPanel.logged-as": "Ви увійшли як",
"authPanel.logout": "Вийти?",
"authPanel.new-page": "нова сторінка",
"authPanel.read-only": "Тільки для читання",
"authPanel.request-to-delete-data": "Запросити видалення моїх даних",
"authPanel.show-settings": "Показати налаштування",
"blockingDuration.day": "На день",
"blockingDuration.month": "На місяць",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Старі",
"commentsSort.recently-updated": "Нещодавно оновлені",
"commentsSort.worst": "Гірші",
"empty-state": "Don't have comments yet",
"errors.0": "Щось пішло не так,спробуйте ще раз пізніше.",
"errors.1": "Коментар не знайдений. Перезавантажте сторінку і спробуйте ще раз.",
"errors.10": "Час редагування коментаря минув.",
@@ -94,6 +94,7 @@
"errors.18": "Запрашиваемый файл не найден.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Не вдалося обробити відповідь від сервера.",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "Недостатньо прав на здійснення цієї дії.",
"errors.4": "Неправильно відформатований коментар.",
"errors.5": "Коментар не знайдений. Перезавантажте сторінку і спробуйте ще раз.",
@@ -106,6 +107,8 @@
"errors.not-authorized": "Не авторизований.",
"errors.to-many-request": "Занадто багато запитів.",
"errors.unexpected-error": "Щось пішло не так.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Закріпленні коментарі",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Показати ще",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "Упорядкованний список",
"toolbar.quote": "Цитата",
"toolbar.unordered-list": "Неупорядкованний список",
"user-info.last-comments": "Останні коментарі {userName}",
"user-info.unexpected-error": "Щось пішло не так",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "Не можна голосувати анонімному користувачу",
"vote.deleted": "Не можна голосувати за видалений коментар",
"vote.guest": "Увійдіть в систему для голосування",
+10 -7
View File
@@ -2,10 +2,12 @@
"auth.back": "Quay lại",
"auth.email-address": "Địa chỉ Email",
"auth.loading": "Đang tải...",
"auth.oauth-button": "Sign In with {provider}",
"auth.oauth-button": "Đăng nhập with {provider}",
"auth.oauth-source": "Use Social Network",
"auth.open-profile": "Open My Profile",
"auth.or": "hoặc",
"auth.signin": "Sign In",
"auth.signin": "Đăng nhập",
"auth.signout": "Thoát",
"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",
@@ -15,11 +17,8 @@
"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",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "Cũ nhất",
"commentsSort.recently-updated": "Mới được cập nhật",
"commentsSort.worst": "Tệ nhất",
"empty-state": "Don't have comments yet",
"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.",
@@ -94,6 +94,7 @@
"errors.18": "Không tìm thấy file được yêu cầu.",
"errors.19": "Comment contains restricted words.",
"errors.2": "Yêu cầu đến không quản lý được.",
"errors.20": "Posted image not found. Please try to upload it again.",
"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.",
@@ -106,6 +107,8 @@
"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.",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "Bình buận đã ghim",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Xem thêm",
@@ -153,8 +156,8 @@
"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",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"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",
+8 -5
View File
@@ -4,8 +4,10 @@
"auth.loading": "加载中...",
"auth.oauth-button": "使用 {provider} 登录",
"auth.oauth-source": "使用社交网络",
"auth.open-profile": "Open My Profile",
"auth.or": "或",
"auth.signin": "登录",
"auth.signout": "登出",
"auth.submit": "提交",
"auth.symbols-restriction": "用户名只能包含字母、数字、下划线或空格",
"auth.user-not-found": "未找到用户",
@@ -15,11 +17,8 @@
"authPanel.enable-comments": "启用评论",
"authPanel.enable-cookies": "允许使用 Cookie 登录并发表评论",
"authPanel.hide-settings": "隐藏设置",
"authPanel.logged-as": "您以以下身份登录",
"authPanel.logout": "登出?",
"authPanel.new-page": "新页面",
"authPanel.read-only": "只读",
"authPanel.request-to-delete-data": "要求删除我的数据",
"authPanel.show-settings": "显示设置",
"blockingDuration.day": "一天",
"blockingDuration.month": "一个月",
@@ -81,6 +80,7 @@
"commentsSort.oldest": "最旧",
"commentsSort.recently-updated": "最近更新",
"commentsSort.worst": "最差",
"empty-state": "Don't have comments yet",
"errors.0": "出了些问题,请稍后再试。",
"errors.1": "找不到评论。请刷新页面,然后重试。",
"errors.10": "现在编辑评论已经太晚了。",
@@ -94,6 +94,7 @@
"errors.18": "找不到请求的文件。",
"errors.19": "Comment contains restricted words.",
"errors.2": "处理传入请求失败。",
"errors.20": "Posted image not found. Please try to upload it again.",
"errors.3": "您无权执行此操作。",
"errors.4": "评论数据无效。",
"errors.5": "找不到评论。请刷新页面,然后重试。",
@@ -106,6 +107,8 @@
"errors.not-authorized": "未经授权。",
"errors.to-many-request": "您已达到最大请求数限制。",
"errors.unexpected-error": "出了些问题。",
"profile.close": "Close profile",
"profile.request-to-delete-data": "Request my data removal",
"root.pinned-comments": "固定的评论",
"root.powered-by": "由 <a>Remark42</a> 提供支持",
"root.show-more": "显示更多",
@@ -153,8 +156,8 @@
"toolbar.ordered-list": "添加编号列表",
"toolbar.quote": "插入引用文本",
"toolbar.unordered-list": "添加项目符号列表",
"user-info.last-comments": "最近由 {userName} 发表的评论",
"user-info.unexpected-error": "出了些问题",
"user.my-comments": "My recent comments",
"user.recent-comments": "Recent comments",
"vote.anonymous": "匿名用户无法投票",
"vote.deleted": "无法为已删除的评论投票",
"vote.guest": "登录以投票",
+124
View File
@@ -0,0 +1,124 @@
import { setAttributes, setStyles, StylesDeclaration } from 'utils/set-dom-props';
import { createIframe } from 'utils/create-iframe';
import type { Profile } from 'common/types';
let root: HTMLDivElement | null = null;
let iframe: HTMLIFrameElement | null = null;
function removeIframe() {
iframe?.parentNode?.removeChild(iframe);
}
function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
styles: StylesDeclaration,
attrs?: Record<string, string>
) {
const element = document.createElement(tagName);
setStyles(element, styles);
setAttributes(element, attrs);
return element;
}
function createFragment(params: Profile & Record<string, string | unknown>) {
removeIframe();
iframe = createIframe({ ...params, page: 'profile', styles: styles.iframe });
if (!root) {
root = createElement('div', styles.root);
document.body.appendChild(root);
}
root.appendChild(iframe);
setStyles(root, styles.rootShowed);
setTimeout(() => iframe?.focus());
}
function animateAppear(): void {
window.requestAnimationFrame(() => {
if (!root || !iframe) {
return;
}
setStyles(root, styles.rootAppear);
});
}
function animateDisappear(): Promise<void> {
return new Promise((resolve) => {
function handleTransitionEnd() {
resolve();
if (!root) {
return;
}
setStyles(root, styles.rootHidden);
root.removeEventListener('transitionend', handleTransitionEnd);
}
window.requestAnimationFrame(() => {
if (!root || !iframe) {
return;
}
setStyles(root, styles.rootDissapear);
root.addEventListener('transitionend', handleTransitionEnd);
});
});
}
function handleKeydown(evt: KeyboardEvent) {
console.log(evt.code);
if (evt.code !== 'Escape') {
return;
}
closeProfile();
}
export function openProfile(params: Profile & Record<string, string | unknown>) {
setStyles(document.body, { overflow: 'hidden' });
createFragment(params);
animateAppear();
window.addEventListener('keydown', handleKeydown);
}
export function closeProfile() {
window.removeEventListener('keydown', handleKeydown);
animateDisappear().then(() => {
removeIframe();
document.body.style.removeProperty('overflow');
});
}
const styles = {
root: {
display: 'none',
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
width: '100%',
height: '100%',
transition: 'opacity 0.5s ease-in',
background: 'rgba(0, 0, 0, .4)',
opacity: 0,
zIndex: 99999999,
},
rootShowed: {
display: 'block',
},
rootHidden: {
display: 'none',
},
rootAppear: {
opacity: '1',
},
rootDissapear: {
opacity: '0',
transition: 'opacity 0.3s ease-out',
},
iframe: {
position: 'absolute',
right: 0,
width: '100%',
height: '100%',
},
};
+23 -4
View File
@@ -5,16 +5,17 @@ import { IntlProvider } from 'react-intl';
import { loadLocale } from 'utils/loadLocale';
import { getLocale } from 'utils/getLocale';
import { parseQuery } from 'utils/parse-query';
import { parseMessage } from 'utils/post-message';
import { parseBooleansFromDictionary } from 'utils/parse-booleans-from-dictionary';
import { ConnectedRoot } from 'components/root';
import { UserInfo } from 'components/user-info';
import { Profile } from 'components/profile';
import { store } from 'store';
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 { restoreCollapsedThreads } from 'store/thread/actions';
import { parseQuery } from 'utils/parseQuery';
import { parseBooleansFromDictionary } from 'utils/parse-booleans-from-dictionary';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -36,6 +37,24 @@ async function init(): Promise<void> {
const messages = await loadLocale(locale).catch(() => ({}));
const boundActions = bindActionCreators({ fetchHiddenUsers, restoreCollapsedThreads }, store.dispatch);
node.innerHTML = '';
window.addEventListener('message', (evt) => {
const data = parseMessage(evt);
if (data.theme === 'light') {
document.body.classList.remove('dark');
}
if (data.theme === 'dark') {
document.body.classList.add('dark');
}
});
if (params.theme === 'dark') {
document.body.classList.add('dark');
}
boundActions.fetchHiddenUsers();
boundActions.restoreCollapsedThreads();
@@ -45,7 +64,7 @@ async function init(): Promise<void> {
render(
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>{params.page === 'user-info' ? <UserInfo /> : <ConnectedRoot />}</Provider>
<Provider store={store}>{params.page === 'profile' ? <Profile /> : <ConnectedRoot />}</Provider>
</IntlProvider>,
node
);
+1 -8
View File
@@ -3,13 +3,6 @@ import { POST_INFO_ACTIONS } from './post-info/types';
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';
/** Merged store actions */
export type ACTIONS =
| COMMENTS_ACTIONS
| POST_INFO_ACTIONS
| THEME_ACTIONS
| THREAD_ACTIONS
| USER_ACTIONS
| USER_INFO_ACTIONS;
export type ACTIONS = COMMENTS_ACTIONS | POST_INFO_ACTIONS | THEME_ACTIONS | THREAD_ACTIONS | USER_ACTIONS;
-2
View File
@@ -2,7 +2,6 @@ import * as comments from './comments/reducers';
import * as postInfo from './post-info/reducers';
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';
/** Merged store reducers */
@@ -10,7 +9,6 @@ export const rootProvider = {
...comments,
...theme,
...postInfo,
...userInfo,
...thread,
...user,
};
-17
View File
@@ -1,17 +0,0 @@
import { getUserComments } from 'common/api';
import { Comment } from 'common/types';
import { StoreAction } from '../index';
import { USER_INFO_SET } from './types';
export const fetchInfo = (id: string): StoreAction<Promise<Comment[] | null>> => async (dispatch) => {
// TODO: limit
const info = await getUserComments(id, 10);
dispatch({
type: USER_INFO_SET,
id,
comments: info.comments,
});
return info.comments;
};
-20
View File
@@ -1,20 +0,0 @@
import type { Comment } from 'common/types';
import { USER_INFO_SET, USER_INFO_ACTIONS } from './types';
export interface UserCommentsState {
[key: string]: Comment[];
}
export function userComments(state: UserCommentsState = {}, action: USER_INFO_ACTIONS): UserCommentsState {
switch (action.type) {
case USER_INFO_SET: {
return {
...state,
[action.id]: action.comments,
};
}
default:
return state;
}
}
-11
View File
@@ -1,11 +0,0 @@
import { Comment, User } from 'common/types';
export const USER_INFO_SET = 'USER-INFO/SET';
export interface USER_INFO_SET_ACTION {
type: typeof USER_INFO_SET;
id: User['id'];
comments: Comment[];
}
export type USER_INFO_ACTIONS = USER_INFO_SET_ACTION;
+15 -2
View File
@@ -1,8 +1,9 @@
import * as api from 'common/api';
import { logout } from 'components/auth/auth.api';
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';
import { LS_EMAIL_KEY, LS_HIDDEN_USERS_KEY } from 'common/constants';
import { setItem } from 'common/local-storage';
import { StoreAction } from '../index';
@@ -17,7 +18,7 @@ import {
USER_SUBSCRIPTION_SET,
USER_SET_ACTION,
} from './types';
import { fetchComments } from '../comments/actions';
import { fetchComments, unsetCommentMode } from '../comments/actions';
import { COMMENTS_PATCH } from '../comments/types';
export function setUser(user: User | null = null): USER_SET_ACTION {
@@ -27,6 +28,18 @@ export function setUser(user: User | null = null): USER_SET_ACTION {
};
}
export function signout(cleanSession = true): StoreAction<Promise<void>> {
return async (dispatch) => {
if (cleanSession) {
await logout();
}
localStorage.removeItem(LS_EMAIL_KEY);
dispatch(setUser());
dispatch(unsetCommentMode());
dispatch(fetchComments());
};
}
export const fetchUser = (): StoreAction<Promise<User | null>> => async (dispatch) => {
const user = await api.getUser();
dispatch(setUser(user));
@@ -61,7 +61,9 @@
/* Named variables */
--primary-color: 0, 170, 170;
--primary-brighter-color: 0, 153, 153;
--primary-text-color: 38, 38, 38;
--secondary-text-color: 100, 116, 139;
--primary-background-color: 255, 255, 255;
--black-color: 0, 0, 0;
--white-color: 255, 255, 255;
--error-color: #b91c1c;
@@ -74,7 +76,11 @@
--line-color: var(--color36);
--primary-color: 0, 153, 153;
--primary-brighter-color: 0, 170, 170;
--primary-text-color: 241, 245, 249;
--primary-background-color: 38, 38, 38;
--secondary-text-color: 209, 213, 219;
--error-color: #ffa0a0;
--line-brighter-color: var(--color11);
color-scheme: dark;
}
+24 -1
View File
@@ -4,10 +4,33 @@ body {
margin: 0;
padding: 6px;
font-family: PT Sans, Helvetica, Arial, sans-serif;
color: rgb(var(--primary-text-color));
box-sizing: border-box;
}
input,
button {
font-family: inherit;
color: inherit;
}
button {
padding: 0;
margin: 0;
border: 0;
background: unset;
font-size: inherit;
transition-property: color, background, border-color;
transition-timing-function: linear;
transition-duration: 150ms;
appearance: none;
}
#remark42 {
height: 100%;
}
/* Preloader */
.preloader,
.preloader::before,
.preloader::after {
+13
View File
@@ -0,0 +1,13 @@
import { h, ComponentChild } from 'preact';
import { IntlProvider } from 'react-intl';
import { render as originalRender } from '@testing-library/preact';
import en from 'locales/en.json';
export function render(children: ComponentChild) {
return originalRender(
<IntlProvider locale="en" messages={en}>
{children}
</IntlProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { BASE_URL } from 'common/constants.config';
import { setStyles, setAttributes, StylesDeclaration } from 'utils/set-dom-props';
type Params = { [key: string]: unknown; __colors__?: Record<string, string>; styles?: StylesDeclaration };
export function createIframe({ __colors__, styles, ...params }: Params) {
const iframe = document.createElement('iframe');
const query = new URLSearchParams(params as Record<string, string>).toString();
setAttributes(iframe, {
src: `${BASE_URL}/web/iframe.html?${query}`,
name: JSON.stringify({ __colors__ }),
frameborder: '0',
allowtransparency: 'true',
scrolling: 'no',
tabindex: '0',
title: 'Comments | Remark42',
horizontalscrolling: 'no',
verticalscrolling: 'no',
});
setStyles(iframe, {
height: '100%',
width: '100%',
border: 'none',
padding: 0,
margin: 0,
overflow: 'hidden',
...styles,
});
return iframe;
}
@@ -1,4 +1,4 @@
import { parseQuery } from './parseQuery';
import { parseQuery } from './parse-query';
describe('parseQuery', () => {
it('should return empty object', () => {
@@ -1,10 +1,11 @@
import type { Theme, UserInfo } from 'common/types';
import type { Theme, Profile } from 'common/types';
type ParentMessage = {
inited?: true;
scrollTo?: number;
height?: number;
profile?: UserInfo | null;
signout?: true;
profile?: Profile | null;
};
type ChildMessage = {
@@ -12,6 +13,7 @@ type ChildMessage = {
hash?: string;
title?: string;
theme?: Theme;
signout?: true;
};
type AllMessages = ChildMessage & ParentMessage;
+23
View File
@@ -0,0 +1,23 @@
export type StylesDeclaration = Partial<
Record<
keyof Omit<
CSSStyleDeclaration,
'length' | 'parentRule' | 'setProperty' | 'removeProperty' | 'item' | 'getPropertyValue' | 'getPropertyPriority'
>,
string | number
>
>;
export function setStyles(element: HTMLElement, styles: StylesDeclaration = {}) {
const entr = Object.entries(styles);
entr.forEach(([p, v]) => {
element.style[p as keyof StylesDeclaration] = `${v}`;
});
}
export function setAttributes(element: HTMLElement, attrs: Record<string, string | number> = {}) {
Object.entries(attrs).forEach(([p, v]) => {
element.setAttribute(p, `${v}`);
});
}
+1 -1
View File
@@ -20,7 +20,6 @@ module.exports = {
setupFilesAfterEnv: [
'<rootDir>/app/__mocks__/fetch.ts',
'<rootDir>/app/__mocks__/localstorage.ts',
'<rootDir>/app/__stubs__/react-intl.ts',
'<rootDir>/app/__stubs__/remark-config.ts',
'<rootDir>/app/__stubs__/static-config.ts',
'<rootDir>/app/__stubs__/settings.ts',
@@ -31,6 +30,7 @@ module.exports = {
'!**/__stubs__/**',
'!app/locales/**',
'!app/utils/loadLocale.ts',
'!app/tests',
],
globals: {
'ts-jest': {
-76
View File
@@ -33,82 +33,6 @@
}
})();
</script>
<!-- TODO: we have to move styles in css file -->
<style>
html,
body {
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
padding: 6px;
}
@keyframes bouncing {
0%,
80%,
100% {
box-shadow: 0 10px 0 -10px;
}
40% {
box-shadow: 0 10px 0 0;
}
}
.preloader,
.preloader::before,
.preloader::after {
border-radius: 50%;
width: 10px;
height: 10px;
text-align: left;
animation-name: bouncing;
animation-timing-function: ease-in-out;
animation-fill-mode: both;
animation-duration: 1.8s;
animation-iteration-count: infinite;
}
.preloader {
/* stylelint-disable-next-line */
color: #fff;
color: var(--color6, #fff);
position: relative;
transform: translate3d(0, -10px, 0);
animation-delay: -0.16s;
}
.preloader::before,
.preloader::after {
content: '';
position: absolute;
top: 0;
}
.preloader::before {
transform: translate3d(-15px, 0, 0);
animation-delay: -0.32s;
}
.preloader::after {
transform: translate3d(15px, 0, 0);
}
.preloader_view_iframe {
margin: 0 auto;
/* stylelint-disable-next-line */
color: #888;
color: var(--color13, #888);
}
:focus:not(.focus-visible):not(.button) {
outline: none;
}
</style>
<% if (htmlWebpackPlugin.options.env === 'production') { %>
<link rel="stylesheet" href="remark.css" />
<% } %>
+2 -2
View File
@@ -3,7 +3,7 @@
"incremental": true,
"target": "es6",
"module": "esnext",
"lib": ["es2018", "dom"],
"lib": ["es2019", "dom"],
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
@@ -12,7 +12,7 @@
"allowJs": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": "app" ,
"baseUrl": "app",
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true
+11 -3
View File
@@ -19,7 +19,7 @@ const PORT = process.env.PORT || 9000;
const REMARK_API_BASE_URL = process.env.REMARK_API_BASE_URL || 'http://127.0.0.1:8080';
const DEVSERVER_BASE_PATH = process.env.DEVSERVER_BASE_PATH || 'http://127.0.0.1:9000';
const PUBLIC_FOLDER_PATH = path.resolve(__dirname, 'public');
const CUSTOM_PROPERTIES_PATH = path.resolve(__dirname, './app/custom-properties.css');
const CUSTOM_PROPERTIES_PATH = path.resolve(__dirname, './app/styles/custom-properties.css');
const genId = incstr.idGenerator();
const modulesMap = {};
@@ -138,8 +138,16 @@ module.exports = (_, { mode, analyze }) => {
sourceMap: isDev,
postcssOptions: {
plugins: [
['postcss-preset-env', { stage: 0 }],
['postcss-custom-properties', { importFrom: CUSTOM_PROPERTIES_PATH }],
[
'postcss-preset-env',
{
browsers: 'defaults, not IE 11, not samsung 12',
stage: 0,
features: {
'custom-properties': CUSTOM_PROPERTIES_PATH,
},
},
],
'cssnano',
],
},