Nice inputs and buttons (#510)
* Set default font in examle * prettify file * add ui elements * Use UIButton at AuthPanel * sort deps * remove unsed getUserTitle method * move UserId inside AuthPanel * tests * use UIButton and UInput in Anonymous login * Use UIButton and UIInput in email login * Replace Button to UIButton * fix context for onTitleClick in dropdown * rearrage class props in dropdown * TODO: change finding over DOM to using ref * Use UIButton in input * rearrage deps * Use UIButton in comment * test * Focus and Input buttons * custom focus style for inputs and buttons * the same focus style for comment input * align buttons by top line in input * Token dropdown & hover fix * disable hover when button disabled * make token dropdown markup in new style * Fix trailing comma * update prettier * move prettier config to json format because it is more hendy for settings (for example vscode can suggest rules) * prettier fixed trailing comman in ejs * Remove unnecessary conditions * Files was formatted by Prettier * fix cursor pointer on collapse button * Move components and right naming * ui-button -> button * ui-input -> input * input -> comment-form * Change cursor behavior on disabled state * Change button style * font-weight: normal by default * add small border-radius * Change another one button to component * Fix autofocus on username in email login form * use preact inbuild autoFocus * fix autofocus on back from token step * sleep for 0s enough to wait next render before focus * use more specific name for username input * fix line-height at auth line on mobile * fix className
This commit is contained in:
@@ -19,6 +19,7 @@ module.exports = {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
'jsx-a11y/no-autofocus': 0,
|
||||
// disabling because typescipt uses it's own lint (see next rule)
|
||||
'no-unused-vars': 0,
|
||||
// allow Rust-like var starting with _underscore
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"overrides": [{
|
||||
"files": ["*.ejs"],
|
||||
"options": {
|
||||
"trailingComma": "none"
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
bracketSpacing: true,
|
||||
arrowParens: 'avoid',
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ declare module 'bem-react-helper' {
|
||||
export interface Mods {
|
||||
[key: string]: string | number | boolean | undefined | null;
|
||||
}
|
||||
export type Mix = string[] | string;
|
||||
export type Mix = Array<string | undefined> | string;
|
||||
export default function b(
|
||||
classname: string,
|
||||
props?: {
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface Config {
|
||||
max_comment_size: number;
|
||||
admins: string[];
|
||||
admin_email: string;
|
||||
auth_providers: (AuthProvider['name'])[];
|
||||
auth_providers: AuthProvider['name'][];
|
||||
low_score: number;
|
||||
critical_score: number;
|
||||
positive_score: boolean;
|
||||
|
||||
+2
-13
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.auth-panel-anonymous-login-form__input {
|
||||
width: 15em;
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.auth-panel-anonymous-login-form__remember-me {
|
||||
@@ -14,16 +14,5 @@
|
||||
}
|
||||
|
||||
.auth-panel-anonymous-login-form__submit {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0.5em;
|
||||
color: currentColor;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-panel-anonymous-login-form__submit:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
+12
-7
@@ -3,6 +3,9 @@ import { createElement, Component, createRef } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
interface Props {
|
||||
onSubmit(username: string): Promise<void>;
|
||||
theme: Theme;
|
||||
@@ -76,10 +79,9 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<form className={className} onSubmit={this.onSubmit}>
|
||||
<input
|
||||
className="auth-panel-anonymous-login-form__input"
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
type="text"
|
||||
mix="auth-panel-anonymous-login-form__input"
|
||||
placeholder="Username"
|
||||
value={this.state.inputValue}
|
||||
onInput={this.onChange}
|
||||
@@ -93,13 +95,16 @@ export class AnonymousLoginForm extends Component<Props, State> {
|
||||
onChange={this.onCheckedChange}
|
||||
checked={this.state.honeyPotValue}
|
||||
/>
|
||||
<input
|
||||
className="auth-panel-anonymous-login-form__submit"
|
||||
<Button
|
||||
mix="auth-panel-anonymous-login-form__submit"
|
||||
type="submit"
|
||||
value="Log in"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
title={usernameInvalidReason || ''}
|
||||
disabled={usernameInvalidReason !== null}
|
||||
/>
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
.auth-panel__dropdown-provider {
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
+18
-19
@@ -13,35 +13,33 @@
|
||||
|
||||
.auth-panel-email-login-form__token-input {
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
border: 1px solid #c4c4c4;
|
||||
padding: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
|
||||
border-color: #259c9a;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel-email-login-form__submit {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 0.1em;
|
||||
margin-top: 0.2em;
|
||||
color: currentColor;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-panel-email-login-form__submit:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
margin: 0.3rem 0.15rem 0.15rem;
|
||||
}
|
||||
|
||||
.auth-panel-email-login-form__back-button {
|
||||
color: #259c9a;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-left: 0.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
&:before {
|
||||
content: '◄';
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +47,5 @@
|
||||
color: #9a0000;
|
||||
text-align: center;
|
||||
margin-top: 1em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
+41
-45
@@ -8,7 +8,9 @@ import { sendEmailVerificationRequest } from '@app/common/api';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import TextareaAutosize from '@app/components/input/textarea-autosize';
|
||||
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
const mapStateToProps = () => ({
|
||||
sendEmailVerification: sendEmailVerificationRequest,
|
||||
@@ -36,7 +38,7 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
static usernameRegex = /^[a-zA-Z][\w ]+$/;
|
||||
static emailRegex = /[^@]+@[^.]+\..+/;
|
||||
|
||||
inputRef = createRef<HTMLInputElement>();
|
||||
usernameInputRef = createRef<HTMLInputElement>();
|
||||
tokenRef = createRef<TextareaAutosize>();
|
||||
|
||||
constructor(props: Props) {
|
||||
@@ -62,8 +64,8 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
|
||||
async focus() {
|
||||
await sleep(100);
|
||||
if (this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
if (this.usernameInputRef.current) {
|
||||
this.usernameInputRef.current.focus();
|
||||
return;
|
||||
}
|
||||
this.tokenRef.current && this.tokenRef.current.textareaRef && this.tokenRef.current.textareaRef.select();
|
||||
@@ -115,15 +117,23 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
this.setState({ error: null, tokenValue: (e.target as HTMLInputElement).value });
|
||||
}
|
||||
|
||||
goBack() {
|
||||
async goBack() {
|
||||
// Wait for finding back button in DOM by dropbox
|
||||
// It prevents dropdown from closing, because if dropdown doesn't find clicked element it closes
|
||||
await sleep(0);
|
||||
|
||||
this.setState({
|
||||
tokenValue: '',
|
||||
error: null,
|
||||
verificationSent: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.inputRef.current && this.inputRef.current.focus();
|
||||
}, 100);
|
||||
|
||||
// Wait for rendering username+email step to find user input
|
||||
await sleep(0);
|
||||
|
||||
if (this.usernameInputRef.current) {
|
||||
this.usernameInputRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getForm1InvalidReason(): string | null {
|
||||
@@ -142,12 +152,6 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.inputRef.current && this.inputRef.current.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
render(props: Props) {
|
||||
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
|
||||
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
|
||||
@@ -160,42 +164,31 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
if (!this.state.verificationSent)
|
||||
return (
|
||||
<form className={className} onSubmit={this.onVerificationSubmit}>
|
||||
{/*
|
||||
* We adding hidden span element to bear with DropDown's onOutSideClick handler.
|
||||
* This function checks if element that was clicked is a children of it's root component.
|
||||
* And the problem is that by the time handler gets executed our target element is not a
|
||||
* part of a dom, so handler suggests that we clicked somewhere outside and hides dropdown
|
||||
*/}
|
||||
<span
|
||||
className="auth-panel-email-login-form__back-button"
|
||||
role="button"
|
||||
{...getHandleClickProps(this.goBack)}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
{'< Back'}
|
||||
</span>
|
||||
<input
|
||||
className="auth-panel-email-login-form__input"
|
||||
ref={this.inputRef}
|
||||
type="text"
|
||||
<Input
|
||||
autoFocus
|
||||
mix="auth-panel-email-login-form__input"
|
||||
ref={this.usernameInputRef}
|
||||
placeholder="Username"
|
||||
value={this.state.usernameValue}
|
||||
onInput={this.onUsernameChange}
|
||||
/>
|
||||
<input
|
||||
className="auth-panel-email-login-form__input"
|
||||
<Input
|
||||
mix="auth-panel-email-login-form__input"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
value={this.state.addressValue}
|
||||
onInput={this.onAddressChange}
|
||||
/>
|
||||
<input
|
||||
className="auth-panel-email-login-form__submit"
|
||||
<Button
|
||||
mix="auth-panel-email-login-form__submit"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
type="submit"
|
||||
value="Send Verification"
|
||||
title={form1InvalidReason || ''}
|
||||
disabled={form1InvalidReason !== null}
|
||||
/>
|
||||
>
|
||||
Send Verification
|
||||
</Button>
|
||||
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
|
||||
</form>
|
||||
);
|
||||
@@ -204,9 +197,9 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<form className={className} onSubmit={this.onSubmit}>
|
||||
<span className="auth-panel-email-login-form__back-button" role="button" {...getHandleClickProps(this.goBack)}>
|
||||
{'< Back'}
|
||||
</span>
|
||||
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
|
||||
Back
|
||||
</Button>
|
||||
<TextareaAutosize
|
||||
autofocus={true}
|
||||
className="auth-panel-email-login-form__token-input"
|
||||
@@ -217,13 +210,16 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<input
|
||||
className="auth-panel-email-login-form__submit"
|
||||
<Button
|
||||
mix="auth-panel-email-login-form__submit"
|
||||
type="submit"
|
||||
value="Confirm"
|
||||
kind="primary"
|
||||
size="middle"
|
||||
title={form2InvalidReason || ''}
|
||||
disabled={form2InvalidReason !== null}
|
||||
/>
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.auth-panel__pseudo-link {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
/* important so "anonymous" label in Dropdown component
|
||||
will not be overriden by `.dropdown_theme_light .dropdown__title` style */
|
||||
color: #0aa !important;
|
||||
|
||||
&:hover {
|
||||
color: #06c5c5 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.auth-panel__sign-out {
|
||||
margin-left: 5px;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
.auth-panel__user-id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 5px 15px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-panel__user-dropdown-title {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, JSX, FunctionComponent } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
theme: Theme;
|
||||
} & JSX.HTMLAttributes;
|
||||
|
||||
export const UserID: FunctionComponent<Props> = ({ id, theme, ...props }) => (
|
||||
<div {...props} className={b('auth-panel__user-id', {}, { theme })} title={id}>
|
||||
{id}
|
||||
</div>
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
export { UserID } from './auth-panel__user-id';
|
||||
|
||||
import './auth-panel__user-id.scss';
|
||||
@@ -4,4 +4,5 @@
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { Button } from '@app/components/button';
|
||||
import { User, PostInfo } from '@app/common/types';
|
||||
|
||||
import { Props, AuthPanel } from './auth-panel';
|
||||
import { User, PostInfo } from '../../common/types';
|
||||
|
||||
const DefaultProps: Partial<Props> = {
|
||||
sort: '-score',
|
||||
@@ -29,7 +32,7 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
expect(authForm.text()).toEqual(expect.stringContaining('Sign in to comment using'));
|
||||
|
||||
const providerLinks = authForm.find('.auth-panel__pseudo-link');
|
||||
const providerLinks = authForm.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('Google');
|
||||
expect(providerLinks.at(1).text()).toEqual('GitHub');
|
||||
@@ -49,7 +52,7 @@ describe('<AuthPanel />', () => {
|
||||
const providerLinks = element
|
||||
.find('.auth-panel__column')
|
||||
.first()
|
||||
.find('.auth-panel__pseudo-link');
|
||||
.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('GitHub');
|
||||
expect(providerLinks.at(1).text()).toEqual('Google');
|
||||
@@ -69,7 +72,7 @@ describe('<AuthPanel />', () => {
|
||||
const providerLinks = element
|
||||
.find('.auth-panel__column')
|
||||
.first()
|
||||
.find('.auth-panel__pseudo-link');
|
||||
.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('Google');
|
||||
expect(providerLinks.at(1).text()).toEqual('GitHub');
|
||||
@@ -94,7 +97,7 @@ describe('<AuthPanel />', () => {
|
||||
|
||||
expect(authForm.text()).toEqual(expect.stringContaining('Sign in using Google or GitHub'));
|
||||
|
||||
const providerLinks = authForm.find('.auth-panel__pseudo-link');
|
||||
const providerLinks = authForm.find(Button);
|
||||
|
||||
expect(providerLinks.at(0).text()).toEqual('Google');
|
||||
expect(providerLinks.at(1).text()).toEqual('GitHub');
|
||||
|
||||
@@ -7,15 +7,15 @@ import { requestDeletion } from '@app/utils/email';
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
import { User, AuthProvider, Sorting, Theme, PostInfo } from '@app/common/types';
|
||||
|
||||
import Dropdown, { DropdownItem } from '@app/components/dropdown';
|
||||
import { Button } from '@app/components/button';
|
||||
import { UserID } from './__user-id';
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
import { EmailLoginFormConnected } from './__email-login-form';
|
||||
import { StoreState } from '@app/store';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
import debounce from '@app/utils/debounce';
|
||||
import postMessage from '@app/utils/postMessage';
|
||||
import { StoreState } from '@app/store';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
import Dropdown, { DropdownItem } from '@app/components/dropdown';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
import { EmailLoginFormConnected } from './__email-login-form';
|
||||
import { EmailLoginFormRef } from './__email-login-form/auth-panel__email-login-form';
|
||||
|
||||
export interface Props {
|
||||
@@ -25,7 +25,7 @@ export interface Props {
|
||||
isCommentsDisabled: boolean;
|
||||
theme: Theme;
|
||||
postInfo: PostInfo;
|
||||
providers: (AuthProvider['name'])[];
|
||||
providers: AuthProvider['name'][];
|
||||
provider: ProviderState;
|
||||
|
||||
onSortChange(s: Sorting): Promise<void>;
|
||||
@@ -129,11 +129,6 @@ export class AuthPanel extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
getUserTitle() {
|
||||
const { user } = this.props;
|
||||
return <span className="auth-panel__username">{user!.name}</span>;
|
||||
}
|
||||
|
||||
/** wrapper function to handle both oauth and anonymous providers*/
|
||||
onSignIn(provider: AuthProvider) {
|
||||
this.props.onSignIn(provider);
|
||||
@@ -153,7 +148,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
}
|
||||
|
||||
renderAuthorized = () => {
|
||||
const { user, onSignOut } = this.props;
|
||||
const { user, onSignOut, theme } = this.props;
|
||||
if (!user) return null;
|
||||
|
||||
const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_';
|
||||
@@ -161,20 +156,26 @@ export class AuthPanel extends Component<Props, State> {
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
You signed in as{' '}
|
||||
<Dropdown title={user.name} theme={this.props.theme}>
|
||||
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
|
||||
<DropdownItem separator={!isUserAnonymous}>
|
||||
<UserID id={user.id} theme={this.props.theme} {...getHandleClickProps(this.toggleUserInfoVisibility)} />
|
||||
<div
|
||||
id={user.id}
|
||||
className={b('auth-panel__user-id', {}, { theme })}
|
||||
{...getHandleClickProps(this.toggleUserInfoVisibility)}
|
||||
>
|
||||
{user.id}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
|
||||
{!isUserAnonymous && (
|
||||
<DropdownItem>
|
||||
<Button kind="link" theme={this.props.theme} onClick={() => requestDeletion().then(onSignOut)}>
|
||||
<Button theme={theme} onClick={() => requestDeletion().then(onSignOut)}>
|
||||
Request my data removal
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</Dropdown>{' '}
|
||||
<Button className="auth-panel__sign-out" kind="link" theme={this.props.theme} onClick={onSignOut}>
|
||||
<Button kind="link" theme={theme} onClick={onSignOut}>
|
||||
Sign out?
|
||||
</Button>
|
||||
</div>
|
||||
@@ -186,7 +187,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
return (
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES['anonymous']}
|
||||
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
|
||||
theme={this.props.theme}
|
||||
>
|
||||
<DropdownItem>
|
||||
@@ -203,7 +204,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
return (
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES['email']}
|
||||
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
|
||||
theme={this.props.theme}
|
||||
onTitleClick={this.onEmailTitleClick}
|
||||
>
|
||||
@@ -220,25 +221,21 @@ export class AuthPanel extends Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
<Button
|
||||
mix={dropdown ? 'auth-panel__dropdown-provider' : ''}
|
||||
kind="link"
|
||||
data-provider={provider}
|
||||
{...getHandleClickProps(this.handleOAuthLogin)}
|
||||
role="link"
|
||||
>
|
||||
{PROVIDER_NAMES[provider]}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
renderOther = (providers: (AuthProvider['name'])[]) => {
|
||||
renderOther = (providers: AuthProvider['name'][]) => {
|
||||
return (
|
||||
<Dropdown
|
||||
title="Other"
|
||||
titleClass="auth-panel__pseudo-link"
|
||||
theme={this.props.theme}
|
||||
onTitleClick={this.onEmailTitleClick}
|
||||
>
|
||||
<Dropdown title="Other" theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
|
||||
{providers.map(provider => (
|
||||
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
|
||||
))}
|
||||
@@ -323,26 +320,28 @@ export class AuthPanel extends Component<Props, State> {
|
||||
|
||||
renderSettingsLabel = () => {
|
||||
return (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
<Button
|
||||
kind="link"
|
||||
mix="auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
|
||||
role="link"
|
||||
>
|
||||
{this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
renderReadOnlySwitch = () => {
|
||||
const { isCommentsDisabled } = this.props;
|
||||
return (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
<Button
|
||||
kind="link"
|
||||
mix="auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
|
||||
role="link"
|
||||
>
|
||||
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,18 +5,14 @@ import './auth-panel.scss';
|
||||
import './__readonly-label/auth-panel__readonly-label.scss';
|
||||
|
||||
import './__column/auth-panel__column.scss';
|
||||
import './__pseudo-link/auth-panel__pseudo-link.scss';
|
||||
import './__select/auth-panel__select.scss';
|
||||
import './__select-label/auth-panel__select-label.scss';
|
||||
import './__select-label-value/auth-panel__select-label-value.scss';
|
||||
import './__sort/auth-panel__sort.scss';
|
||||
|
||||
import './__user-id/auth-panel__user-id.scss';
|
||||
import './__sign-out/auth-panel__sign-out.scss';
|
||||
|
||||
import './_theme/_dark/auth-panel_theme_dark.scss';
|
||||
import './_theme/_light/auth-panel_theme_light.scss';
|
||||
|
||||
import './_logged-in/auth-panel_logged-in.scss';
|
||||
|
||||
import './__dropdown-provider/auth-panel__dropdown-provider.scss';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.button_focused:not(.button_clicked) {
|
||||
outline-width: 5px;
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
.button_kind_link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
color: #0aa;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.button_kind_primary {
|
||||
background: #259c9a;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #06c5c5;
|
||||
}
|
||||
|
||||
&:hover&:disabled {
|
||||
background: #259c9a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.button_kind_secondary {
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 2px #06c5c5;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.button_kind_text {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.button_size_large {
|
||||
height: 2rem;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.button_size_middle {
|
||||
height: 2rem;
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.button_theme_dark.button_kind_secondary {
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
.button {
|
||||
cursor: pointer;
|
||||
outline-width: 0;
|
||||
font-size: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,26 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, JSX, Component } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import noop from '@app/utils/noop';
|
||||
import { createElement, JSX } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import b, { Mods, Mix } from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
type Props = {
|
||||
interface Props extends Omit<JSX.HTMLAttributes, 'size'> {
|
||||
kind?: 'primary' | 'secondary' | 'link';
|
||||
size?: 'middle' | 'large';
|
||||
theme?: Theme;
|
||||
mods?: Mods;
|
||||
mix?: Mix;
|
||||
type?: string;
|
||||
kind?: string;
|
||||
theme: Theme;
|
||||
mix?: string;
|
||||
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
onFocus?: (e: FocusEvent) => void;
|
||||
onBlur?: (e: FocusEvent) => void;
|
||||
} & JSX.HTMLAttributes;
|
||||
|
||||
interface State {
|
||||
isClicked: boolean;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
export class Button extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
};
|
||||
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
}
|
||||
|
||||
onMouseDown() {
|
||||
this.setState({
|
||||
isClicked: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClick(e: MouseEvent) {
|
||||
this.props.onClick!(e);
|
||||
}
|
||||
|
||||
onBlur(e: FocusEvent) {
|
||||
this.setState({
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
});
|
||||
|
||||
this.props.onBlur!(e);
|
||||
}
|
||||
|
||||
onFocus(e: FocusEvent) {
|
||||
this.setState({
|
||||
isFocused: true,
|
||||
});
|
||||
|
||||
this.props.onFocus!(e);
|
||||
}
|
||||
|
||||
render(props: Props, state: State) {
|
||||
const { children, className } = props;
|
||||
const { isClicked, isFocused } = state;
|
||||
|
||||
let rclassName = b(
|
||||
'button',
|
||||
{ mix: props.mix },
|
||||
{ theme: props.theme, type: props.type, kind: props.kind, clicked: isClicked, focused: isFocused }
|
||||
);
|
||||
if (className) {
|
||||
rclassName +=
|
||||
' ' +
|
||||
b(
|
||||
className,
|
||||
{},
|
||||
{ theme: props.theme, type: props.type, kind: props.kind, clicked: isClicked, focused: isFocused }
|
||||
);
|
||||
}
|
||||
|
||||
const localProps = { ...props };
|
||||
delete localProps.children;
|
||||
delete localProps.mix;
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(
|
||||
({ children, theme, mods, mix, kind, type = 'button', size, ...props }) => {
|
||||
const className = b('button', { mods: { kind, size }, mix }, { theme, ...mods });
|
||||
|
||||
return (
|
||||
<button
|
||||
{...localProps}
|
||||
className={rclassName}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
>
|
||||
<button className={className} type={type} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.defaultProps = {
|
||||
type: 'button',
|
||||
onClick: noop,
|
||||
onBlur: noop,
|
||||
onFocus: noop,
|
||||
};
|
||||
);
|
||||
|
||||
@@ -3,6 +3,10 @@ export { Button } from './button';
|
||||
import './button.scss';
|
||||
|
||||
import './_kind/_link/button_kind_link.scss';
|
||||
import './_kind/_text/button_kind_text.scss';
|
||||
import './_kind/_primary/button_kind_primary.scss';
|
||||
import './_kind/_secondary/button_kind_secondary.scss';
|
||||
|
||||
import './_focused/button_focused.scss';
|
||||
import './_size/_large/button_size_large.scss';
|
||||
import './_size/_middle/button_size_middle.scss';
|
||||
|
||||
import './_theme/_dark/button_theme_dark.scss';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__actions {
|
||||
.comment-form__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__button_type_preview {
|
||||
.comment-form__button_type_preview {
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px #0aa;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__button_type_send {
|
||||
.comment-form__button_type_send {
|
||||
background: #259c9a;
|
||||
color: #fff;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.comment-form__button {
|
||||
margin: 8px 8px 0 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__control-panel {
|
||||
.comment-form__control-panel {
|
||||
height: 30px;
|
||||
background-color: #eee;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__counter {
|
||||
.comment-form__counter {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__error {
|
||||
.comment-form__error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
@@ -0,0 +1,3 @@
|
||||
.comment-form__field-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
.input__field {
|
||||
.comment-form__field {
|
||||
$lineHeight: 1.4;
|
||||
$fontSize: 16px;
|
||||
$paddingVrt: 10px;
|
||||
@@ -20,7 +20,8 @@
|
||||
transform: translateZ(0); /* let's try to fix blinking in Safari, again */
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px #259c9a;
|
||||
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
|
||||
border-color: #259c9a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__markdown-link {
|
||||
.comment-form__markdown-link {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
.input__toolbar {
|
||||
.comment-form__toolbar {
|
||||
display: block;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.input__toolbar-file-input {
|
||||
.comment-form__toolbar-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
@@ -11,7 +11,7 @@
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
.input__toolbar-item {
|
||||
.comment-form__toolbar-item {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #586069;
|
||||
@@ -24,16 +24,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.input__toolbar-icon {
|
||||
.comment-form__toolbar-icon {
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.input__toolbar-group {
|
||||
.comment-form__toolbar-group {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.input__toolbar-group:first-child {
|
||||
.comment-form__toolbar-group:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.comment-form__markdown {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.comment-form__preview-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__preview {
|
||||
.comment-form__preview {
|
||||
margin-top: 8px;
|
||||
padding: 7px 11px;
|
||||
overflow: hidden;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__rss-link {
|
||||
.comment-form__rss-link {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.input__rss {
|
||||
.comment-form__rss {
|
||||
margin-left: auto;
|
||||
align-self: flex-end;
|
||||
font-size: 12px;
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
.input_simple {
|
||||
.comment-form_simple {
|
||||
border-width: 12px;
|
||||
}
|
||||
+10
-10
@@ -1,49 +1,49 @@
|
||||
.input_theme_dark {
|
||||
.comment-form_theme_dark {
|
||||
border-color: #333;
|
||||
background: #22201c; /* try to fix textarea blinking in Safari */
|
||||
|
||||
.input__actions {
|
||||
.comment-form__actions {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.input__button_type_preview {
|
||||
.comment-form__button_type_preview {
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.input__button_type_send {
|
||||
.comment-form__button_type_send {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.input__error {
|
||||
.comment-form__error {
|
||||
border-top: 8px solid #333;
|
||||
background: #672323;
|
||||
color: #f98989;
|
||||
}
|
||||
|
||||
.input__field {
|
||||
.comment-form__field {
|
||||
background: #22201c;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.input__preview {
|
||||
.comment-form__preview {
|
||||
border-color: #22201c;
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.input__preview-wrapper {
|
||||
.comment-form__preview-wrapper {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.input__toolbar-item {
|
||||
.comment-form__toolbar-item {
|
||||
color: #ddd;
|
||||
&:hover {
|
||||
color: #0aa;
|
||||
}
|
||||
}
|
||||
|
||||
.input__control-panel {
|
||||
.comment-form__control-panel {
|
||||
background-color: #333;
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,38 +1,38 @@
|
||||
.input_theme_light {
|
||||
.comment-form_theme_light {
|
||||
border-color: #eee;
|
||||
background: #fff; /* try to fix textarea blinking in Safari */
|
||||
|
||||
.input__actions {
|
||||
.comment-form__actions {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.input__button_type_preview {
|
||||
.comment-form__button_type_preview {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.input__button_type_send {
|
||||
.comment-form__button_type_send {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.input__error {
|
||||
.comment-form__error {
|
||||
border-top: 8px solid #eee;
|
||||
background: #ffd7d7;
|
||||
color: #9a0000;
|
||||
}
|
||||
|
||||
.input__field {
|
||||
.comment-form__field {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.input__preview {
|
||||
.comment-form__preview {
|
||||
border-color: #eee;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input__preview-wrapper {
|
||||
.comment-form__preview-wrapper {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.comment-form {
|
||||
position: relative;
|
||||
display: block;
|
||||
font-size: 0;
|
||||
border-style: solid;
|
||||
border-width: 6px 12px 12px 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { CommentForm, Props } from './comment-form';
|
||||
|
||||
describe('<CommentForm />', () => {
|
||||
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
|
||||
const element = shallow(<CommentForm {...({ simpleView: true } as Props)} />);
|
||||
|
||||
expect(element.exists('.comment-form__control-panel')).toEqual(false);
|
||||
expect(element.exists('.comment-form__button_type_preview')).toEqual(false);
|
||||
expect(element.exists('.comment-form__rss')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import b, { Mix } from 'bem-react-helper';
|
||||
|
||||
import { User, Theme, Image, ApiError } from '@app/common/types';
|
||||
import { BASE_URL, API_BASE } from '@app/common/constants';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import { siteId, url, pageTitle } from '@app/common/settings';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { replaceSelection } from '@app/utils/replaceSelection';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
import MarkdownToolbar from './markdown-toolbar';
|
||||
import TextareaAutosize from './textarea-autosize';
|
||||
|
||||
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
|
||||
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
|
||||
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
|
||||
|
||||
let textareaId = 0;
|
||||
|
||||
export interface Props {
|
||||
/** user id for rss link generation */
|
||||
userId?: User['id'];
|
||||
errorMessage?: string;
|
||||
value?: string;
|
||||
mix?: Mix;
|
||||
mode?: 'main' | 'edit' | 'reply';
|
||||
theme: Theme;
|
||||
simpleView?: boolean;
|
||||
autofocus?: boolean;
|
||||
|
||||
onSubmit(text: string, pageTitle: string): Promise<void>;
|
||||
getPreview(text: string): Promise<string>;
|
||||
/** action on cancel. optional as root input has no cancel option */
|
||||
onCancel?: () => void;
|
||||
uploadImage?: (image: File) => Promise<Image>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
preview: string | null;
|
||||
isErrorShown: boolean;
|
||||
/** error message, if contains newlines, it will be splitted to multiple errors */
|
||||
errorMessage: string | null;
|
||||
/** prevents error hiding on input event */
|
||||
errorLock: boolean;
|
||||
isDisabled: boolean;
|
||||
maxLength: number;
|
||||
/** main input value */
|
||||
text: string;
|
||||
/** override main button text */
|
||||
buttonText: null | string;
|
||||
}
|
||||
|
||||
const Labels = {
|
||||
main: 'Send',
|
||||
edit: 'Save',
|
||||
reply: 'Reply',
|
||||
};
|
||||
|
||||
const ImageMimeRegex = /image\//i;
|
||||
|
||||
export class CommentForm extends Component<Props, State> {
|
||||
/** reference to textarea element */
|
||||
textAreaRef = createRef<TextareaAutosize>();
|
||||
textareaId: string;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
textareaId = textareaId + 1;
|
||||
this.textareaId = `textarea_${textareaId}`;
|
||||
this.state = {
|
||||
preview: null,
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
errorLock: false,
|
||||
isDisabled: false,
|
||||
maxLength: StaticStore.config.max_comment_size,
|
||||
text: props.value || '',
|
||||
buttonText: null,
|
||||
};
|
||||
|
||||
this.send = this.send.bind(this);
|
||||
this.getPreview = this.getPreview.bind(this);
|
||||
this.onInput = this.onInput.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
this.appendError = this.appendError.bind(this);
|
||||
this.uploadImage = this.uploadImage.bind(this);
|
||||
this.uploadImages = this.uploadImages.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.value !== this.props.value) {
|
||||
this.setState({ text: nextProps.value || '' });
|
||||
this.props.autofocus && this.textAreaRef.current && this.textAreaRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: State) {
|
||||
return (
|
||||
nextProps.mode !== this.props.mode ||
|
||||
nextProps.theme !== this.props.theme ||
|
||||
nextProps.userId !== this.props.userId ||
|
||||
nextProps.value !== this.props.value ||
|
||||
nextProps.errorMessage !== this.props.errorMessage ||
|
||||
nextState !== this.state
|
||||
);
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
// send on cmd+enter / ctrl+enter
|
||||
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
|
||||
this.send(e);
|
||||
}
|
||||
}
|
||||
|
||||
onInput(e: Event) {
|
||||
if (this.state.errorLock) {
|
||||
this.setState({
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
async onPaste(e: ClipboardEvent) {
|
||||
if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
await this.uploadImages(files);
|
||||
}
|
||||
|
||||
send(e: Event) {
|
||||
const text = this.state.text;
|
||||
const props = this.props;
|
||||
|
||||
if (e) e.preventDefault();
|
||||
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
if (text === this.props.value) {
|
||||
this.props.onCancel && this.props.onCancel();
|
||||
this.setState({ preview: null, text: '' });
|
||||
}
|
||||
|
||||
this.setState({ isDisabled: true, isErrorShown: false });
|
||||
|
||||
props
|
||||
.onSubmit(text, pageTitle || document.title)
|
||||
.then(() => {
|
||||
this.setState({ preview: null, text: '' });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
const errorMessage = extractErrorMessageFromResponse(e);
|
||||
this.setState({ isErrorShown: true, errorMessage });
|
||||
})
|
||||
.finally(() => this.setState({ isDisabled: false }));
|
||||
}
|
||||
|
||||
getPreview() {
|
||||
const text = this.state.text;
|
||||
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
this.setState({ isErrorShown: false, errorMessage: null });
|
||||
|
||||
this.props
|
||||
.getPreview(text)
|
||||
.then(preview => this.setState({ preview }))
|
||||
.catch(() => {
|
||||
this.setState({ isErrorShown: true, errorMessage: null });
|
||||
});
|
||||
}
|
||||
|
||||
/** appends error to input's error block */
|
||||
appendError(...errors: string[]) {
|
||||
if (!this.state.errorMessage) {
|
||||
this.setState({
|
||||
errorMessage: errors.join('\n'),
|
||||
isErrorShown: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
errorMessage: this.state.errorMessage + '\n' + errors.join('\n'),
|
||||
isErrorShown: true,
|
||||
});
|
||||
}
|
||||
|
||||
onDragOver(e: DragEvent) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (StaticStore.config.max_image_size === 0) return;
|
||||
if (!this.textAreaRef) return;
|
||||
if (!e.dataTransfer) return;
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
if (Array.from(items).filter(i => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
onDrop(e: DragEvent) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (StaticStore.config.max_image_size === 0) return;
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const data = Array.from(e.dataTransfer.files).filter(f => ImageMimeRegex.test(f.type));
|
||||
if (data.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.uploadImages(data);
|
||||
}
|
||||
|
||||
/** wrapper with error handling for props.uploadImage */
|
||||
uploadImage(file: File): Promise<Image | Error> {
|
||||
return this.props.uploadImage!(file).catch(
|
||||
(e: ApiError | string) =>
|
||||
new Error(
|
||||
typeof e === 'string'
|
||||
? `${file.name} upload failed with "${e}"`
|
||||
: `${file.name} upload failed with "${e.error}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** performs upload process */
|
||||
async uploadImages(files: File[]) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (!this.textAreaRef.current) return;
|
||||
|
||||
/** Human readable image size limit, i.e 5MB */
|
||||
const maxImageSizeString = (StaticStore.config.max_image_size / 1024 / 1024).toFixed(2) + 'MB';
|
||||
/** upload delay to avoid server rate limiter */
|
||||
const uploadDelay = 5000;
|
||||
|
||||
const isSelectionSupported = this.textAreaRef.current.isSelectionSupported();
|
||||
|
||||
this.setState({
|
||||
errorLock: true,
|
||||
errorMessage: null,
|
||||
isErrorShown: false,
|
||||
isDisabled: true,
|
||||
buttonText: 'Uploading...',
|
||||
});
|
||||
|
||||
// fallback for ie < 9
|
||||
if (!isSelectionSupported) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: this.state.text + markdownString,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
|
||||
const uploadPlaceholderLength = uploadPlaceholder.length;
|
||||
const selection = this.textAreaRef.current.getSelection();
|
||||
/** saved selection in case of error */
|
||||
const originalText = this.state.text;
|
||||
const restoreSelection = async () => {
|
||||
this.setState({
|
||||
text: originalText,
|
||||
});
|
||||
/** sleeping awhile so textarea catch state change and its selection */
|
||||
await sleep(100);
|
||||
this.textAreaRef.current!.setSelection(selection);
|
||||
};
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, selection, uploadPlaceholder),
|
||||
});
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
await restoreSelection();
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString),
|
||||
});
|
||||
/** sleeping awhile so textarea catch state change and its selection */
|
||||
await sleep(100);
|
||||
const selectionPointer = selection[0] + markdownString.length;
|
||||
this.textAreaRef.current.setSelection([selectionPointer, selectionPointer]);
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
}
|
||||
|
||||
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
|
||||
const charactersLeft = maxLength - text.length;
|
||||
errorMessage = props.errorMessage || errorMessage;
|
||||
const label = buttonText || Labels[props.mode || 'main'];
|
||||
|
||||
return (
|
||||
<form
|
||||
className={b('comment-form', {
|
||||
mods: {
|
||||
theme: props.theme,
|
||||
type: props.mode || 'reply',
|
||||
simple: props.simpleView,
|
||||
},
|
||||
mix: props.mix,
|
||||
})}
|
||||
onSubmit={this.send}
|
||||
aria-label="New comment"
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
{!props.simpleView && (
|
||||
<div className="comment-form__control-panel">
|
||||
<MarkdownToolbar
|
||||
allowUpload={Boolean(this.props.uploadImage)}
|
||||
uploadImages={this.uploadImages}
|
||||
textareaId={this.textareaId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="comment-form__field-wrapper">
|
||||
<TextareaAutosize
|
||||
id={this.textareaId}
|
||||
onPaste={this.onPaste}
|
||||
ref={this.textAreaRef}
|
||||
className="comment-form__field"
|
||||
placeholder="Your comment here"
|
||||
value={text}
|
||||
maxLength={maxLength}
|
||||
onInput={this.onInput}
|
||||
onKeyDown={this.onKeyDown}
|
||||
disabled={isDisabled}
|
||||
autofocus={!!props.autofocus}
|
||||
spellcheck={true}
|
||||
/>
|
||||
|
||||
{charactersLeft < 100 && <span className="comment-form__counter">{charactersLeft}</span>}
|
||||
</div>
|
||||
|
||||
{(isErrorShown || !!errorMessage) &&
|
||||
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
|
||||
<p className="comment-form__error" role="alert" key={e}>
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="comment-form__actions">
|
||||
{!props.simpleView && (
|
||||
<Button
|
||||
kind="secondary"
|
||||
theme={props.theme}
|
||||
size="large"
|
||||
mix="comment-form__button"
|
||||
disabled={isDisabled}
|
||||
onClick={this.getPreview}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{!props.simpleView && props.mode === 'main' && (
|
||||
<div className="comment-form__rss">
|
||||
<div className="comment-form__markdown">
|
||||
Styling with{' '}
|
||||
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
|
||||
Markdown
|
||||
</a>{' '}
|
||||
is supported
|
||||
</div>
|
||||
Subscribe to the{' '}
|
||||
<a className="comment-form__rss-link" href={RSS_THREAD_URL} target="_blank">
|
||||
Thread
|
||||
</a>
|
||||
{', '}
|
||||
<a className="comment-form__rss-link" href={RSS_SITE_URL} target="_blank">
|
||||
Site
|
||||
</a>{' '}
|
||||
or
|
||||
<a className="comment-form__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
|
||||
Replies
|
||||
</a>{' '}
|
||||
by RSS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{// TODO: it can be more elegant;
|
||||
// for example it can render full comment component here (or above textarea on mobile)
|
||||
!!preview && (
|
||||
<div className="comment-form__preview-wrapper">
|
||||
<div
|
||||
className={b('comment-form__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { CommentForm } from './comment-form';
|
||||
|
||||
import '@app/components/raw-content';
|
||||
|
||||
import './comment-form.scss';
|
||||
|
||||
import './__actions/comment-form__actions.scss';
|
||||
|
||||
import './__button/comment-form__button.scss';
|
||||
import './__button/_type/_preview/comment-form__button_type_preview.scss';
|
||||
import './__button/_type/_send/comment-form__button_type_send.scss';
|
||||
|
||||
import './__control-panel/comment-form__control-panel.scss';
|
||||
import './__counter/comment-form__counter.scss';
|
||||
import './__error/comment-form__error.scss';
|
||||
import './__field/comment-form__field.scss';
|
||||
import './__field-wrapper/comment-form__field-wrapper.scss';
|
||||
import './__preview/comment-form__preview.scss';
|
||||
import './__preview-wrapper/comment-form__preview-wrapper.scss';
|
||||
import './__rss/comment-form__rss.scss';
|
||||
import './__rss-link/comment-form__rss-link.scss';
|
||||
import './__markdown/comment-form__markdown.scss';
|
||||
import './__markdown-link/comment-form__markdown-link.scss';
|
||||
import './__markdown-toolbar/comment-form__markdown-toolbar.scss';
|
||||
|
||||
import './_theme/_dark/comment-form_theme_dark.scss';
|
||||
import './_theme/_light/comment-form_theme_light.scss';
|
||||
|
||||
import './_simple/comment-form_simple.scss';
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function BoldIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 10 16" version="1.1" width="10" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 10 16"
|
||||
version="1.1"
|
||||
width="10"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M1 2h3.83c2.48 0 4.3.75 4.3 2.95 0 1.14-.63 2.23-1.67 2.61v.06c1.33.3 2.3 1.23 2.3 2.86 0 2.39-1.97 3.52-4.61 3.52H1V2zm3.66 4.95c1.67 0 2.38-.66 2.38-1.69 0-1.17-.78-1.61-2.34-1.61H3.13v3.3h1.53zm.27 5.39c1.77 0 2.75-.64 2.75-1.98 0-1.27-.95-1.81-2.75-1.81h-1.8v3.8h1.8v-.01z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function CodeIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 14 16"
|
||||
version="1.1"
|
||||
width="14"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.5 3L8 4.5 11.5 8 8 11.5 9.5 13 14 8 9.5 3zm-5 0L0 8l4.5 5L6 11.5 2.5 8 6 4.5 4.5 3z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function HeaderIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 18 16" version="1.1" width="18" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 18 16"
|
||||
version="1.1"
|
||||
width="18"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13.62 9.08L12.1 3.66h-.06l-1.5 5.42h3.08zM5.7 10.13S4.68 6.52 4.53 6.02h-.08l-1.13 4.11H5.7zM17.31 14h-2.25l-.95-3.25h-4.07L9.09 14H6.84l-.69-2.33H2.87L2.17 14H0l3.3-9.59h2.5l2.17 6.34L10.86 2h2.52l3.94 12h-.01z"
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function ImageIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
|
||||
<svg className="comment-form__toolbar-icon" width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function ItalicIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 6 16" version="1.1" width="6" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 6 16"
|
||||
version="1.1"
|
||||
width="6"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.81 5h1.98L3 14H1l1.81-9zm.36-2.7c0-.7.58-1.3 1.33-1.3.56 0 1.13.38 1.13 1.03 0 .75-.59 1.3-1.33 1.3-.58 0-1.13-.38-1.13-1.03z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function LinkIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function OrderedListIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 12 16"
|
||||
version="1.1"
|
||||
width="12"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.01 13c0 .59 0 1-.59 1H4.6c-.59 0-.59-.41-.59-1 0-.59 0-1 .59-1h6.81c.59 0 .59.41.59 1h.01zM4.6 4h6.81C12 4 12 3.59 12 3c0-.59 0-1-.59-1H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1zm6.81 3H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1h6.81C12 9 12 8.59 12 8c0-.59 0-1-.59-1zm-9.4-6h-.72c-.3.19-.58.25-1.03.34V2h.75v2.14H.17V5h2.84v-.86h-1V1zm.392 8.12c-.129 0-.592.04-.802.07.53-.56 1.14-1.25 1.14-1.89C2.72 6.52 2.18 6 1.38 6c-.59 0-.97.2-1.38.64l.58.58c.19-.19.38-.38.64-.38.28 0 .48.16.48.52 0 .53-.77 1.2-1.7 2.06V10h3v-.88h-.598zm-.222 3.79v-.03c.44-.19.64-.47.64-.86 0-.7-.56-1.11-1.44-1.11-.48 0-.89.19-1.28.52l.55.64c.25-.2.44-.31.69-.31.27 0 .42.13.42.36 0 .27-.2.44-.86.44v.75c.83 0 .98.17.98.47 0 .25-.23.38-.58.38-.28 0-.56-.14-.81-.38l-.48.66c.3.36.77.56 1.41.56.83 0 1.53-.41 1.53-1.16 0-.5-.31-.81-.77-.94v.01z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function QuoteIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 14 16"
|
||||
version="1.1"
|
||||
width="14"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.16 3.5C3.73 5.06 2.55 6.67 2.55 9.36c.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.9 0-2.99-1.52-2.99-4.25 0-3.8 1.75-6.53 5.02-8.42L6.16 3.5zm7 0c-2.43 1.56-3.61 3.17-3.61 5.86.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.89 0-2.98-1.52-2.98-4.25 0-3.8 1.75-6.53 5.02-8.42l1.14 1.84h-.01z"
|
||||
+8
-1
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
|
||||
|
||||
export default function UnorderedListIcon() {
|
||||
return (
|
||||
<svg className="input__toolbar-icon" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
|
||||
<svg
|
||||
className="comment-form__toolbar-icon"
|
||||
viewBox="0 0 12 16"
|
||||
version="1.1"
|
||||
width="12"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 13c0 .59 0 1-.59 1H.59C0 14 0 13.59 0 13c0-.59 0-1 .59-1h.81c.59 0 .59.41.59 1H2zm2.59-9h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1H4.59C4 2 4 2.41 4 3c0 .59 0 1 .59 1zM1.41 7H.59C0 7 0 7.41 0 8c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0-5H.59C0 2 0 2.41 0 3c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm10 5H4.59C4 7 4 7.41 4 8c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0 5H4.59C4 12 4 12.41 4 13c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01z"
|
||||
+22
-14
@@ -50,40 +50,48 @@ export default class MarkdownToolbar extends Component<Props> {
|
||||
}
|
||||
render(props: Props) {
|
||||
return (
|
||||
<markdown-toolbar className="input__toolbar" for={props.textareaId}>
|
||||
<div className="input__toolbar-group">
|
||||
<md-header className="input__toolbar-item" title={headerLabel} aria-label={headerLabel}>
|
||||
<markdown-toolbar className="comment-form__toolbar" for={props.textareaId}>
|
||||
<div className="comment-form__toolbar-group">
|
||||
<md-header className="comment-form__toolbar-item" title={headerLabel} aria-label={headerLabel}>
|
||||
<HeaderIcon />
|
||||
</md-header>
|
||||
<md-bold className="input__toolbar-item" title={boldLabel} aria-label={boldLabel}>
|
||||
<md-bold className="comment-form__toolbar-item" title={boldLabel} aria-label={boldLabel}>
|
||||
<BoldIcon />
|
||||
</md-bold>
|
||||
<md-italic className="input__toolbar-item" title={italicLabel} aria-label={italicLabel}>
|
||||
<md-italic className="comment-form__toolbar-item" title={italicLabel} aria-label={italicLabel}>
|
||||
<ItalicIcon />
|
||||
</md-italic>
|
||||
</div>
|
||||
<div className="input__toolbar-group">
|
||||
<md-quote className="input__toolbar-item" title={quoteLabel} aria-label={quoteLabel}>
|
||||
<div className="comment-form__toolbar-group">
|
||||
<md-quote className="comment-form__toolbar-item" title={quoteLabel} aria-label={quoteLabel}>
|
||||
<QuoteIcon />
|
||||
</md-quote>
|
||||
<md-code className="input__toolbar-item" title={codeLabel} aria-label={codeLabel}>
|
||||
<md-code className="comment-form__toolbar-item" title={codeLabel} aria-label={codeLabel}>
|
||||
<CodeIcon />
|
||||
</md-code>
|
||||
<md-link className="input__toolbar-item" title={linkLabel} aria-label={linkLabel}>
|
||||
<md-link className="comment-form__toolbar-item" title={linkLabel} aria-label={linkLabel}>
|
||||
<LinkIcon />
|
||||
</md-link>
|
||||
{this.props.allowUpload ? (
|
||||
<label className="input__toolbar-item" title={attachImageLabel} aria-label={attachImageLabel}>
|
||||
<input multiple className="input__toolbar-file-input" type="file" onChange={this.uploadImages} />
|
||||
<label className="comment-form__toolbar-item" title={attachImageLabel} aria-label={attachImageLabel}>
|
||||
<input multiple className="comment-form__toolbar-file-input" type="file" onChange={this.uploadImages} />
|
||||
<ImageIcon />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="input__toolbar-group">
|
||||
<md-unordered-list className="input__toolbar-item" title={unorderedListLabel} aria-label={unorderedListLabel}>
|
||||
<div className="comment-form__toolbar-group">
|
||||
<md-unordered-list
|
||||
className="comment-form__toolbar-item"
|
||||
title={unorderedListLabel}
|
||||
aria-label={unorderedListLabel}
|
||||
>
|
||||
<UnorderedListIcon />
|
||||
</md-unordered-list>
|
||||
<md-ordered-list className="input__toolbar-item" title={orderedListLabel} aria-label={orderedListLabel}>
|
||||
<md-ordered-list
|
||||
className="comment-form__toolbar-item"
|
||||
title={orderedListLabel}
|
||||
aria-label={orderedListLabel}
|
||||
>
|
||||
<OrderedListIcon />
|
||||
</md-ordered-list>
|
||||
</div>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
text-align: center;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #0aa;
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
.comment__action_type_delete {
|
||||
&:hover {
|
||||
color: #31c7c5;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.comment__action_type_edit {
|
||||
&:hover {
|
||||
color: #31c7c5;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
.comment__action {
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: #259c9a;
|
||||
|
||||
&:hover {
|
||||
color: #31c7c5;
|
||||
}
|
||||
font-weight: normal;
|
||||
|
||||
+ .comment__action {
|
||||
margin-left: 8px;
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
.comment__control {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
color: #8cd4d4;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0aa;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.comment__action_type_delete {
|
||||
color: #6a6a6a;
|
||||
}
|
||||
|
||||
.comment__action_type_delete,
|
||||
.comment__action_type_edit {
|
||||
color: #6a6a6a;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.comment__action_type_delete {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.comment__action_type_delete,
|
||||
.comment__action_type_edit {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,8 @@ describe('<Comment />', () => {
|
||||
<Comment {...({ ...DefaultProps, user: { ...DefaultProps.user, admin: true } } as Props)} />
|
||||
);
|
||||
|
||||
const controls = element.find('.comment__controls > span');
|
||||
const controls = element.find('.comment__controls').children();
|
||||
|
||||
expect(controls.length).toBe(5);
|
||||
expect(controls.at(0).text()).toEqual('Copy');
|
||||
expect(controls.at(1).text()).toEqual('Pin');
|
||||
@@ -196,7 +197,7 @@ describe('<Comment />', () => {
|
||||
<Comment {...({ ...DefaultProps, user: { ...DefaultProps.user, admin: false } } as Props)} />
|
||||
);
|
||||
|
||||
const controls = element.find('.comment__controls > span');
|
||||
const controls = element.find('.comment__controls').children();
|
||||
expect(controls.length).toBe(1);
|
||||
expect(controls.at(0).text()).toEqual('Hide');
|
||||
});
|
||||
|
||||
@@ -15,9 +15,10 @@ import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode }
|
||||
import { extractErrorMessageFromResponse, FetcherError } from '@app/utils/errorUtils';
|
||||
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
|
||||
|
||||
import { Input } from '@app/components/input';
|
||||
import { CommentForm } from '@app/components/comment-form';
|
||||
import { AvatarIcon } from '@app/components/avatar-icon';
|
||||
import Countdown from '../countdown';
|
||||
import { Button } from '@app/components/button';
|
||||
import Countdown from '@app/components/countdown';
|
||||
import { boundActions } from './connected-comment';
|
||||
import { getPreview, uploadImage } from '@app/common/api';
|
||||
import postMessage from '@app/utils/postMessage';
|
||||
@@ -388,33 +389,33 @@ export class Comment extends Component<Props, State> {
|
||||
this.state.isCopied ? (
|
||||
<span className="comment__control comment__control_view_inactive">Copied!</span>
|
||||
) : (
|
||||
<span {...getHandleClickProps(this.copyComment)} className="comment__control">
|
||||
<Button kind="link" {...getHandleClickProps(this.copyComment)} mix="comment__control">
|
||||
Copy
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
controls.push(
|
||||
<span {...getHandleClickProps(this.togglePin)} className="comment__control">
|
||||
<Button kind="link" {...getHandleClickProps(this.togglePin)} mix="comment__control">
|
||||
{this.props.data.pin ? 'Unpin' : 'Pin'}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCurrentUser) {
|
||||
controls.push(
|
||||
<span {...getHandleClickProps(this.hideUser)} className="comment__control">
|
||||
<Button kind="link" {...getHandleClickProps(this.hideUser)} mix="comment__control">
|
||||
Hide
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
if (this.props.isUserBanned) {
|
||||
controls.push(
|
||||
<span {...getHandleClickProps(this.onUnblockUserClick)} className="comment__control">
|
||||
<Button kind="link" {...getHandleClickProps(this.onUnblockUserClick)} mix="comment__control">
|
||||
Unblock
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,9 +438,9 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
if (!this.props.data.delete) {
|
||||
controls.push(
|
||||
<span {...getHandleClickProps(this.deleteComment)} className="comment__control">
|
||||
<Button kind="link" {...getHandleClickProps(this.deleteComment)} mix="comment__control">
|
||||
Delete
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -678,9 +679,9 @@ export class Comment extends Component<Props, State> {
|
||||
{(!props.collapsed || props.view === 'pinned') && (
|
||||
<div className="comment__actions">
|
||||
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
|
||||
<span {...getHandleClickProps(this.toggleReplying)} className="comment__action">
|
||||
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{!props.data.delete &&
|
||||
!props.disabled &&
|
||||
@@ -688,19 +689,21 @@ export class Comment extends Component<Props, State> {
|
||||
isCurrentUser &&
|
||||
(editable || isEditing) &&
|
||||
props.view === 'main' && [
|
||||
<span
|
||||
<Button
|
||||
kind="link"
|
||||
{...getHandleClickProps(this.toggleEditing)}
|
||||
className="comment__action comment__action_type_edit"
|
||||
mix={['comment__action', 'comment__action_type_edit']}
|
||||
>
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
</span>,
|
||||
</Button>,
|
||||
!isAdmin && (
|
||||
<span
|
||||
<Button
|
||||
kind="link"
|
||||
{...getHandleClickProps(this.deleteComment)}
|
||||
className="comment__action comment__action_type_delete"
|
||||
mix={['comment__action', 'comment__action_type_delete']}
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
</Button>
|
||||
),
|
||||
state.editDeadline && (
|
||||
<Countdown
|
||||
@@ -721,7 +724,7 @@ export class Comment extends Component<Props, State> {
|
||||
</div>
|
||||
|
||||
{isReplying && props.view === 'main' && (
|
||||
<Input
|
||||
<CommentForm
|
||||
theme={props.theme}
|
||||
value=""
|
||||
mode="reply"
|
||||
@@ -736,7 +739,7 @@ export class Comment extends Component<Props, State> {
|
||||
)}
|
||||
|
||||
{isEditing && props.view === 'main' && (
|
||||
<Input
|
||||
<CommentForm
|
||||
theme={props.theme}
|
||||
value={o.orig}
|
||||
mode="edit"
|
||||
|
||||
@@ -4,8 +4,6 @@ import './comment.scss';
|
||||
|
||||
import './__action/comment__action.scss';
|
||||
import './__action/_type/_collapse/comment__action_type_collapse.scss';
|
||||
import './__action/_type/_edit/comment__action_type_edit.scss';
|
||||
import './__action/_type/_delete/comment__action_type_delete.scss';
|
||||
|
||||
import './__edit-timer/comment__edit-timer.scss';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.dropdown__item {
|
||||
& > button,
|
||||
& > a,
|
||||
& > button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
& > .dropdown {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
padding: 5px 15px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&_separator {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
.dropdown__title {
|
||||
border: none;
|
||||
background: none;
|
||||
font-weight: bold;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
content: '\25BE';
|
||||
margin-left: 2px;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
.dropdown_theme_dark {
|
||||
.dropdown__title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.dropdown__content {
|
||||
background-color: #22201c;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
.dropdown_theme_light {
|
||||
.dropdown__title {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.dropdown__content {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { Button } from '@app/components/button';
|
||||
import { Theme } from '@app/common/types';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -25,6 +25,9 @@ interface State {
|
||||
|
||||
export default class Dropdown extends Component<Props, State> {
|
||||
rootNode = createRef<HTMLDivElement>();
|
||||
storedDocumentHeight: string | null = null;
|
||||
storedDocumentHeightSet: boolean = false;
|
||||
checkInterval: number | undefined = undefined;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -40,7 +43,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
this.__onClose = this.__onClose.bind(this);
|
||||
}
|
||||
|
||||
onTitleClick() {
|
||||
onTitleClick = () => {
|
||||
const isActive = !this.state.isActive;
|
||||
const contentTranslateX = isActive ? this.state.contentTranslateX : 0;
|
||||
this.setState(
|
||||
@@ -63,11 +66,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
storedDocumentHeight: string | null = null;
|
||||
storedDocumentHeightSet: boolean = false;
|
||||
checkInterval: number | undefined = undefined;
|
||||
};
|
||||
|
||||
__onOpen() {
|
||||
const isChildOfDropDown = (() => {
|
||||
@@ -90,6 +89,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
if (!this.rootNode.current || !this.state.isActive) return;
|
||||
const windowHeight = window.innerHeight;
|
||||
const dcBottom = (() => {
|
||||
// TODO: use ref
|
||||
const dc = Array.from(this.rootNode.current.children).find(c => c.classList.contains('dropdown__content'));
|
||||
if (!dc) return 0;
|
||||
const rect = dc.getBoundingClientRect();
|
||||
@@ -112,6 +112,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
|
||||
async __adjustDropDownContent() {
|
||||
if (!this.rootNode.current) return;
|
||||
// TODO: use ref
|
||||
const dc = this.rootNode.current.querySelector<HTMLDivElement>('.dropdown__content');
|
||||
if (!dc) return;
|
||||
await sleep(10);
|
||||
@@ -185,11 +186,10 @@ export default class Dropdown extends Component<Props, State> {
|
||||
<Button
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isActive && 'true'}
|
||||
mix="dropdown__title"
|
||||
type="button"
|
||||
onClick={() => this.onTitleClick()}
|
||||
theme="light"
|
||||
className={titleClass}
|
||||
onClick={this.onTitleClick}
|
||||
theme={theme}
|
||||
mix={['dropdown__title', titleClass]}
|
||||
kind="link"
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.input__button {
|
||||
box-sizing: border-box;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.input__field-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.input__markdown {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.input__preview-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export { Input } from './input';
|
||||
|
||||
import './input.scss';
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
.input {
|
||||
position: relative;
|
||||
display: block;
|
||||
font-size: 0;
|
||||
border-style: solid;
|
||||
border-width: 6px 12px 12px 12px;
|
||||
border-radius: 2px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #c4c4c4;
|
||||
margin: 0;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
|
||||
border-color: #259c9a;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Input, Props } from './input';
|
||||
|
||||
describe('<Input />', () => {
|
||||
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
|
||||
const element = shallow(<Input {...({ simpleView: true } as Props)} />);
|
||||
|
||||
expect(element.exists('.input__control-panel')).toEqual(false);
|
||||
expect(element.exists('.input__button_type_preview')).toEqual(false);
|
||||
expect(element.exists('.input__rss')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -1,456 +1,25 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, JSX } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import b, { Mods, Mix } from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
/* styles imports */
|
||||
import '@app/components/raw-content';
|
||||
import './styles';
|
||||
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import b, { Mix } from 'bem-react-helper';
|
||||
|
||||
import { User, Theme, Image, ApiError } from '@app/common/types';
|
||||
import { BASE_URL, API_BASE } from '@app/common/constants';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import { siteId, url, pageTitle } from '@app/common/settings';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
|
||||
import MarkdownToolbar from './markdown-toolbar';
|
||||
import TextareaAutosize from './textarea-autosize';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { replaceSelection } from '@app/utils/replaceSelection';
|
||||
|
||||
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
|
||||
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
|
||||
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
|
||||
|
||||
let textareaId = 0;
|
||||
|
||||
export interface Props {
|
||||
/** user id for rss link generation */
|
||||
userId?: User['id'];
|
||||
errorMessage?: string;
|
||||
value?: string;
|
||||
interface Props extends JSX.HTMLAttributes {
|
||||
kind?: 'primary' | 'secondary';
|
||||
theme?: Theme;
|
||||
mods?: Mods;
|
||||
mix?: Mix;
|
||||
mode?: 'main' | 'edit' | 'reply';
|
||||
theme: Theme;
|
||||
simpleView: boolean | undefined;
|
||||
autofocus?: boolean;
|
||||
|
||||
onSubmit(text: string, pageTitle: string): Promise<void>;
|
||||
getPreview(text: string): Promise<string>;
|
||||
/** action on cancel. optional as root input has no cancel option */
|
||||
onCancel?: () => void;
|
||||
uploadImage?: (image: File) => Promise<Image>;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
preview: string | null;
|
||||
isErrorShown: boolean;
|
||||
/** error message, if contains newlines, it will be splitted to multiple errors */
|
||||
errorMessage: string | null;
|
||||
/** prevents error hiding on input event */
|
||||
errorLock: boolean;
|
||||
isDisabled: boolean;
|
||||
maxLength: number;
|
||||
/** main input value */
|
||||
text: string;
|
||||
/** override main button text */
|
||||
buttonText: null | string;
|
||||
}
|
||||
|
||||
const Labels = {
|
||||
main: 'Send',
|
||||
edit: 'Save',
|
||||
reply: 'Reply',
|
||||
};
|
||||
|
||||
const ImageMimeRegex = /image\//i;
|
||||
|
||||
export class Input extends Component<Props, State> {
|
||||
/** reference to textarea element */
|
||||
textAreaRef = createRef<TextareaAutosize>();
|
||||
textareaId: string;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
textareaId = textareaId + 1;
|
||||
this.textareaId = `textarea_${textareaId}`;
|
||||
this.state = {
|
||||
preview: null,
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
errorLock: false,
|
||||
isDisabled: false,
|
||||
maxLength: StaticStore.config.max_comment_size,
|
||||
text: props.value || '',
|
||||
buttonText: null,
|
||||
};
|
||||
|
||||
this.send = this.send.bind(this);
|
||||
this.getPreview = this.getPreview.bind(this);
|
||||
this.onInput = this.onInput.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
this.appendError = this.appendError.bind(this);
|
||||
this.uploadImage = this.uploadImage.bind(this);
|
||||
this.uploadImages = this.uploadImages.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.value !== this.props.value) {
|
||||
this.setState({ text: nextProps.value || '' });
|
||||
this.props.autofocus && this.textAreaRef.current && this.textAreaRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: State) {
|
||||
return (
|
||||
nextProps.mode !== this.props.mode ||
|
||||
nextProps.theme !== this.props.theme ||
|
||||
nextProps.userId !== this.props.userId ||
|
||||
nextProps.value !== this.props.value ||
|
||||
nextProps.errorMessage !== this.props.errorMessage ||
|
||||
nextState !== this.state
|
||||
);
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
// send on cmd+enter / ctrl+enter
|
||||
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
|
||||
this.send(e);
|
||||
}
|
||||
}
|
||||
|
||||
onInput(e: Event) {
|
||||
if (this.state.errorLock) {
|
||||
this.setState({
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
async onPaste(e: ClipboardEvent) {
|
||||
if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
await this.uploadImages(files);
|
||||
}
|
||||
|
||||
send(e: Event) {
|
||||
const text = this.state.text;
|
||||
const props = this.props;
|
||||
|
||||
if (e) e.preventDefault();
|
||||
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
if (text === this.props.value) {
|
||||
this.props.onCancel && this.props.onCancel();
|
||||
this.setState({ preview: null, text: '' });
|
||||
}
|
||||
|
||||
this.setState({ isDisabled: true, isErrorShown: false });
|
||||
|
||||
props
|
||||
.onSubmit(text, pageTitle || document.title)
|
||||
.then(() => {
|
||||
this.setState({ preview: null, text: '' });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
const errorMessage = extractErrorMessageFromResponse(e);
|
||||
this.setState({ isErrorShown: true, errorMessage });
|
||||
})
|
||||
.finally(() => this.setState({ isDisabled: false }));
|
||||
}
|
||||
|
||||
getPreview() {
|
||||
const text = this.state.text;
|
||||
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
this.setState({ isErrorShown: false, errorMessage: null });
|
||||
|
||||
this.props
|
||||
.getPreview(text)
|
||||
.then(preview => this.setState({ preview }))
|
||||
.catch(() => {
|
||||
this.setState({ isErrorShown: true, errorMessage: null });
|
||||
});
|
||||
}
|
||||
|
||||
/** appends error to input's error block */
|
||||
appendError(...errors: string[]) {
|
||||
if (!this.state.errorMessage) {
|
||||
this.setState({
|
||||
errorMessage: errors.join('\n'),
|
||||
isErrorShown: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
errorMessage: this.state.errorMessage + '\n' + errors.join('\n'),
|
||||
isErrorShown: true,
|
||||
});
|
||||
}
|
||||
|
||||
onDragOver(e: DragEvent) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (StaticStore.config.max_image_size === 0) return;
|
||||
if (!this.textAreaRef) return;
|
||||
if (!e.dataTransfer) return;
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
if (Array.from(items).filter(i => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
onDrop(e: DragEvent) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (StaticStore.config.max_image_size === 0) return;
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const data = Array.from(e.dataTransfer.files).filter(f => ImageMimeRegex.test(f.type));
|
||||
if (data.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.uploadImages(data);
|
||||
}
|
||||
|
||||
/** wrapper with error handling for props.uploadImage */
|
||||
uploadImage(file: File): Promise<Image | Error> {
|
||||
return this.props.uploadImage!(file).catch(
|
||||
(e: ApiError | string) =>
|
||||
new Error(
|
||||
typeof e === 'string'
|
||||
? `${file.name} upload failed with "${e}"`
|
||||
: `${file.name} upload failed with "${e.error}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** performs upload process */
|
||||
async uploadImages(files: File[]) {
|
||||
if (!this.props.uploadImage) return;
|
||||
if (!this.textAreaRef.current) return;
|
||||
|
||||
/** Human readable image size limit, i.e 5MB */
|
||||
const maxImageSizeString = (StaticStore.config.max_image_size / 1024 / 1024).toFixed(2) + 'MB';
|
||||
/** upload delay to avoid server rate limiter */
|
||||
const uploadDelay = 5000;
|
||||
|
||||
const isSelectionSupported = this.textAreaRef.current.isSelectionSupported();
|
||||
|
||||
this.setState({
|
||||
errorLock: true,
|
||||
errorMessage: null,
|
||||
isErrorShown: false,
|
||||
isDisabled: true,
|
||||
buttonText: 'Uploading...',
|
||||
});
|
||||
|
||||
// fallback for ie < 9
|
||||
if (!isSelectionSupported) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: this.state.text + markdownString,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
|
||||
const uploadPlaceholderLength = uploadPlaceholder.length;
|
||||
const selection = this.textAreaRef.current.getSelection();
|
||||
/** saved selection in case of error */
|
||||
const originalText = this.state.text;
|
||||
const restoreSelection = async () => {
|
||||
this.setState({
|
||||
text: originalText,
|
||||
});
|
||||
/** sleeping awhile so textarea catch state change and its selection */
|
||||
await sleep(100);
|
||||
this.textAreaRef.current!.setSelection(selection);
|
||||
};
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, selection, uploadPlaceholder),
|
||||
});
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
await restoreSelection();
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString),
|
||||
});
|
||||
/** sleeping awhile so textarea catch state change and its selection */
|
||||
await sleep(100);
|
||||
const selectionPointer = selection[0] + markdownString.length;
|
||||
this.textAreaRef.current.setSelection([selectionPointer, selectionPointer]);
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
}
|
||||
|
||||
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
|
||||
const charactersLeft = maxLength - text.length;
|
||||
errorMessage = props.errorMessage || errorMessage;
|
||||
const label = buttonText || Labels[props.mode || 'main'];
|
||||
export const Input = forwardRef<HTMLInputElement, Props>(
|
||||
({ children, theme, mods, mix, type = 'text', ...props }, ref) => {
|
||||
const className = b('input', { mix }, { theme, ...mods });
|
||||
|
||||
return (
|
||||
<form
|
||||
className={b('input', {
|
||||
mods: {
|
||||
theme: props.theme || 'light',
|
||||
type: props.mode || 'reply',
|
||||
simple: props.simpleView,
|
||||
},
|
||||
mix: props.mix,
|
||||
})}
|
||||
onSubmit={this.send}
|
||||
aria-label="New comment"
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
{!props.simpleView && (
|
||||
<div className="input__control-panel">
|
||||
<MarkdownToolbar
|
||||
allowUpload={Boolean(this.props.uploadImage)}
|
||||
uploadImages={this.uploadImages}
|
||||
textareaId={this.textareaId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="input__field-wrapper">
|
||||
<TextareaAutosize
|
||||
id={this.textareaId}
|
||||
onPaste={this.onPaste}
|
||||
ref={this.textAreaRef}
|
||||
className="input__field"
|
||||
placeholder="Your comment here"
|
||||
value={text}
|
||||
maxLength={maxLength}
|
||||
onInput={this.onInput}
|
||||
onKeyDown={this.onKeyDown}
|
||||
disabled={isDisabled}
|
||||
autofocus={!!props.autofocus}
|
||||
spellcheck={true}
|
||||
/>
|
||||
|
||||
{charactersLeft < 100 && <span className="input__counter">{charactersLeft}</span>}
|
||||
</div>
|
||||
|
||||
{(isErrorShown || !!errorMessage) &&
|
||||
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
|
||||
<p className="input__error" role="alert" key={e}>
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="input__actions">
|
||||
{!props.simpleView && (
|
||||
<button
|
||||
className={b('input__button', {}, { type: 'preview' })}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={this.getPreview}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className={b('input__button', {}, { type: 'send' })} type="submit" disabled={isDisabled}>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
{!props.simpleView && props.mode === 'main' && (
|
||||
<div className="input__rss">
|
||||
<div className="input__markdown">
|
||||
Styling with{' '}
|
||||
<a className="input__markdown-link" target="_blank" href="markdown-help.html">
|
||||
Markdown
|
||||
</a>{' '}
|
||||
is supported
|
||||
</div>
|
||||
Subscribe to the{' '}
|
||||
<a className="input__rss-link" href={RSS_THREAD_URL} target="_blank">
|
||||
Thread
|
||||
</a>
|
||||
{', '}
|
||||
<a className="input__rss-link" href={RSS_SITE_URL} target="_blank">
|
||||
Site
|
||||
</a>{' '}
|
||||
or
|
||||
<a className="input__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
|
||||
Replies
|
||||
</a>{' '}
|
||||
by RSS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{// TODO: it can be more elegant;
|
||||
// for example it can render full comment component here (or above textarea on mobile)
|
||||
!!preview && (
|
||||
<div className="input__preview-wrapper">
|
||||
<div
|
||||
className={b('input__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<input ref={ref} className={className} type={type} {...props}>
|
||||
{children}
|
||||
</input>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import '@app/components/raw-content';
|
||||
|
||||
import './input.scss';
|
||||
import './_simple/input_simple.scss';
|
||||
|
||||
import './__actions/input__actions.scss';
|
||||
|
||||
import './__button/input__button.scss';
|
||||
import './__button/_type/_preview/input__button_type_preview.scss';
|
||||
import './__button/_type/_send/input__button_type_send.scss';
|
||||
|
||||
import './__control-panel/input__control-panel.scss';
|
||||
import './__counter/input__counter.scss';
|
||||
import './__error/input__error.scss';
|
||||
import './__field/input__field.scss';
|
||||
import './__field-wrapper/input__field-wrapper.scss';
|
||||
import './__preview/input__preview.scss';
|
||||
import './__preview-wrapper/input__preview-wrapper.scss';
|
||||
import './__rss/input__rss.scss';
|
||||
import './__rss-link/input__rss-link.scss';
|
||||
import './__markdown/input__markdown.scss';
|
||||
import './__markdown-link/input__markdown-link.scss';
|
||||
import './__markdown-toolbar/input__markdown-toolbar.scss';
|
||||
|
||||
import './_theme/_dark/input_theme_dark.scss';
|
||||
import './_theme/_light/input_theme_light.scss';
|
||||
@@ -1,5 +1,5 @@
|
||||
.root__pinned-comments {
|
||||
margin-top: 24px;
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
.root__show-more {
|
||||
display: block;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
height: 36px;
|
||||
margin: 20px auto;
|
||||
padding: 0;
|
||||
background: #259c9a;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: #0aa;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ import { addComment, updateComment } from '@app/store/comments/actions';
|
||||
import { AuthPanel } from '@app/components/auth-panel';
|
||||
import Settings from '@app/components/settings';
|
||||
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
|
||||
import { Input } from '@app/components/input';
|
||||
import { CommentForm } from '@app/components/comment-form';
|
||||
import Preloader from '@app/components/preloader';
|
||||
import { Thread } from '@app/components/thread';
|
||||
import { Button } from '@app/components/button';
|
||||
import { uploadImage, getPreview } from '@app/common/api';
|
||||
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
|
||||
import { bindActions } from '@app/utils/actionBinder';
|
||||
@@ -241,7 +242,7 @@ export class Root extends Component<Props, State> {
|
||||
{!this.props.isSettingsVisible && (
|
||||
<div className="root__main">
|
||||
{!isGuest && !isCommentsDisabled && (
|
||||
<Input
|
||||
<CommentForm
|
||||
theme={props.theme}
|
||||
mix="root__input"
|
||||
mode="main"
|
||||
@@ -284,9 +285,9 @@ export class Root extends Component<Props, State> {
|
||||
))}
|
||||
|
||||
{commentsShown < this.props.topComments.length && IS_MOBILE && (
|
||||
<button className="root__show-more" onClick={this.showMore}>
|
||||
<Button kind="primary" size="middle" mix="root__show-more" onClick={this.showMore}>
|
||||
Show more
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,9 +24,9 @@ interface State {
|
||||
* reapply block on unblocked user
|
||||
*/
|
||||
blockedUsers: BlockedUser[];
|
||||
unblockedUsers: (User['id'])[];
|
||||
unblockedUsers: User['id'][];
|
||||
hiddenUsers: { [id: string]: User };
|
||||
unhiddenUsers: (User['id'])[];
|
||||
unhiddenUsers: User['id'][];
|
||||
}
|
||||
|
||||
export default class Settings extends Component<Props, State> {
|
||||
|
||||
@@ -22,7 +22,7 @@ const mapStateToProps = (state: StoreState, props: { id: CommentInterface['id']
|
||||
|
||||
interface OwnProps {
|
||||
id: CommentInterface['id'];
|
||||
childs?: (CommentInterface['id'])[];
|
||||
childs?: CommentInterface['id'][];
|
||||
level: number;
|
||||
mix?: string;
|
||||
|
||||
|
||||
@@ -12,13 +12,10 @@ export const useActions = <Actions extends { [key: string]: Function }>(
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.entries(actions).reduce<BoundActionCreator<Actions>>(
|
||||
(result, [key, fn]) => {
|
||||
(result as any)[key] = (...args: any[]) => dispatch(fn(...args));
|
||||
return result;
|
||||
},
|
||||
{} as any
|
||||
),
|
||||
Object.entries(actions).reduce<BoundActionCreator<Actions>>((result, [key, fn]) => {
|
||||
(result as any)[key] = (...args: any[]) => dispatch(fn(...args));
|
||||
return result;
|
||||
}, {} as any),
|
||||
[dispatch, ...Object.values(actions)]
|
||||
) as any;
|
||||
};
|
||||
|
||||
@@ -16,12 +16,15 @@ import { getPinnedComments } from './utils';
|
||||
import { cmpRef } from '@app/utils/cmpRef';
|
||||
|
||||
export const topComments = (
|
||||
state: (Comment['id'])[] = [],
|
||||
state: Comment['id'][] = [],
|
||||
action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
|
||||
): (Comment['id'])[] => {
|
||||
): Comment['id'][] => {
|
||||
switch (action.type) {
|
||||
case COMMENTS_SET: {
|
||||
return cmpRef(state, action.comments.map(x => x.comment.id));
|
||||
return cmpRef(
|
||||
state,
|
||||
action.comments.map(x => x.comment.id)
|
||||
);
|
||||
}
|
||||
case COMMENTS_APPEND: {
|
||||
if (action.comment.pid) return state;
|
||||
@@ -32,10 +35,7 @@ export const topComments = (
|
||||
}
|
||||
};
|
||||
|
||||
const reduceChildIds = (
|
||||
c: Record<Comment['id'], (Comment['id'])[]>,
|
||||
x: Node
|
||||
): Record<Comment['id'], (Comment['id'])[]> => {
|
||||
const reduceChildIds = (c: Record<Comment['id'], Comment['id'][]>, x: Node): Record<Comment['id'], Comment['id'][]> => {
|
||||
if (!x.replies) return c;
|
||||
if (!c[x.comment.id]) {
|
||||
c[x.comment.id] = [];
|
||||
@@ -51,12 +51,12 @@ const reduceChildIds = (
|
||||
};
|
||||
|
||||
export const childComments = (
|
||||
state: Record<Comment['id'], (Comment['id'])[]> = {},
|
||||
state: Record<Comment['id'], Comment['id'][]> = {},
|
||||
action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
|
||||
): Record<Comment['id'], (Comment['id'])[]> => {
|
||||
): Record<Comment['id'], Comment['id'][]> => {
|
||||
switch (action.type) {
|
||||
case COMMENTS_SET: {
|
||||
return action.comments.reduce<Record<Comment['id'], (Comment['id'])[]>>(reduceChildIds, {});
|
||||
return action.comments.reduce<Record<Comment['id'], Comment['id'][]>>(reduceChildIds, {});
|
||||
}
|
||||
case COMMENTS_APPEND: {
|
||||
if (!action.comment.pid) return state;
|
||||
@@ -134,9 +134,9 @@ export const activeComment = (
|
||||
};
|
||||
|
||||
export const pinnedComments = (
|
||||
state: (Comment['id'])[] = [],
|
||||
state: Comment['id'][] = [],
|
||||
action: COMMENTS_SET_ACTION | COMMENTS_EDIT_ACTION | COMMENTS_PATCH_ACTION
|
||||
): (Comment['id'])[] => {
|
||||
): Comment['id'][] => {
|
||||
switch (action.type) {
|
||||
case COMMENTS_SET: {
|
||||
return getPinnedComments(action.comments).map(x => x.id);
|
||||
@@ -157,7 +157,7 @@ export const pinnedComments = (
|
||||
if (!action.patch.pin) {
|
||||
return state.filter(x => action.ids.indexOf(x) === -1);
|
||||
}
|
||||
return [...state, ...action.ids].reduce<(Comment['id'])[]>((c, x) => {
|
||||
return [...state, ...action.ids].reduce<Comment['id'][]>((c, x) => {
|
||||
if (c.indexOf(x) === -1) {
|
||||
c.push(x);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const COMMENTS_PATCH = 'COMMENTS/PATCH';
|
||||
|
||||
export interface COMMENTS_PATCH_ACTION {
|
||||
type: typeof COMMENTS_PATCH;
|
||||
ids: (Comment['id'])[];
|
||||
ids: Comment['id'][];
|
||||
patch: Partial<Comment>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Comment } from '@app/common/types';
|
||||
export const THREAD_RESTORE_COLLAPSE = 'THREAD/COLLAPSE_RESTORE';
|
||||
export interface THREAD_RESTORE_COLLAPSE_ACTION {
|
||||
type: typeof THREAD_RESTORE_COLLAPSE;
|
||||
ids: (Comment['id'])[];
|
||||
ids: Comment['id'][];
|
||||
}
|
||||
|
||||
export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET';
|
||||
|
||||
@@ -22,7 +22,7 @@ export const getCollapsedComments = (): string[] =>
|
||||
/**
|
||||
* @param info list of string of type "site-id_url_comment-id
|
||||
*/
|
||||
export const saveCollapsedComments = (siteId: string, url: string, info: (Comment['id'])[]): void => {
|
||||
export const saveCollapsedComments = (siteId: string, url: string, info: Comment['id'][]): void => {
|
||||
const data = info.map(i => `${siteId}_${url}_${i}`);
|
||||
const notForThisPost = getFromLocalStorage().filter(entry => entry.indexOf(`${siteId}_${url}`) === -1);
|
||||
const all = new Set([...notForThisPost, ...data]);
|
||||
|
||||
+111
-108
@@ -1,118 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>remark42 demo page</title>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>remark42 demo page</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.document_theme_dark {
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.document_theme_dark a {
|
||||
color: #5e5eff;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.widget__comments-frame {
|
||||
border: 1px dashed #aaa;
|
||||
width: 35rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.widgets__counter-widget {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.container {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
flex-direction: column
|
||||
}
|
||||
|
||||
.widgets__counter-widget {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
var body = document.querySelector('body');
|
||||
|
||||
if (body.classList.contains('document_theme_dark')) {
|
||||
body.classList.add('document_theme_light');
|
||||
body.classList.remove('document_theme_dark');
|
||||
window.REMARK42.changeTheme('light');
|
||||
} else {
|
||||
body.classList.add('document_theme_dark');
|
||||
body.classList.remove('document_theme_light');
|
||||
window.REMARK42.changeTheme('dark');
|
||||
<style>
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Demo page</h1>
|
||||
<p>To install widgets on your website, follow the <a href="https://github.com/umputun/remark#setup-on-your-website">instructions</a>.</p>
|
||||
<div class="widgets">
|
||||
<div class="widget widgets__widget widgets__comments-widget">
|
||||
<a class="widget__link" href="/web/last-comments.html">Last comments widget page</a><br/>
|
||||
<iframe class="widget__frame widget__comments-frame" src="/web/last-comments.html" frameborder="0"></iframe>
|
||||
</div>
|
||||
<div class="widget widgets__widget widgets__counter-widget">
|
||||
<a class="widget__link" href="/web/counter.html">Counter widget page</a><br/>
|
||||
<div class="widget__frame widget__counter-frame">
|
||||
Comments count: <span class="remark42__counter"></span>
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.document_theme_dark {
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.document_theme_dark a {
|
||||
color: #5e5eff;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.widget__comments-frame {
|
||||
border: 1px dashed #aaa;
|
||||
width: 35rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.widgets__counter-widget {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.container {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widgets__counter-widget {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
var body = document.querySelector('body');
|
||||
|
||||
if (body.classList.contains('document_theme_dark')) {
|
||||
body.classList.add('document_theme_light');
|
||||
body.classList.remove('document_theme_dark');
|
||||
window.REMARK42.changeTheme('light');
|
||||
} else {
|
||||
body.classList.add('document_theme_dark');
|
||||
body.classList.remove('document_theme_light');
|
||||
window.REMARK42.changeTheme('dark');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Demo page</h1>
|
||||
<p>
|
||||
To install widgets on your website, follow the
|
||||
<a href="https://github.com/umputun/remark#setup-on-your-website">instructions</a>.
|
||||
</p>
|
||||
<div class="widgets">
|
||||
<div class="widget widgets__widget widgets__comments-widget">
|
||||
<a class="widget__link" href="/web/last-comments.html">Last comments widget page</a><br />
|
||||
<iframe class="widget__frame widget__comments-frame" src="/web/last-comments.html" frameborder="0"></iframe>
|
||||
</div>
|
||||
<div class="widget widgets__widget widgets__counter-widget">
|
||||
<a class="widget__link" href="/web/counter.html">Counter widget page</a><br />
|
||||
<div class="widget__frame widget__counter-frame">Comments count: <span class="remark42__counter"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p><button onclick="toggleTheme()">Toggle theme</button></p>
|
||||
<div id="remark42"></div>
|
||||
</div>
|
||||
<p><Button onclick="toggleTheme()">Toggle theme</Button></p>
|
||||
<div id="remark42"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var remark_config = {
|
||||
site_id: 'remark',
|
||||
host: window.location.origin,
|
||||
url: 'https://remark42.com/demo/',
|
||||
components: ['embed', 'counter']
|
||||
};
|
||||
<script>
|
||||
var remark_config = {
|
||||
site_id: 'remark',
|
||||
host: window.location.origin,
|
||||
url: 'https://remark42.com/demo/',
|
||||
components: ['embed', 'counter']
|
||||
};
|
||||
|
||||
(function(c) {
|
||||
for(var i = 0; i < c.length; i++){
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = remark_config.host + '/web/' +c[i] +'.js';
|
||||
(d.head || d.body).appendChild(s);
|
||||
}
|
||||
})(remark_config.components || ['embed']);
|
||||
</script>
|
||||
<noscript>
|
||||
Please enable JavaScript to view the comments powered by Remark.
|
||||
</noscript>
|
||||
</body>
|
||||
(function(c) {
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var d = document,
|
||||
s = d.createElement('script');
|
||||
s.src = remark_config.host + '/web/' + c[i] + '.js';
|
||||
(d.head || d.body).appendChild(s);
|
||||
}
|
||||
})(remark_config.components || ['embed']);
|
||||
</script>
|
||||
<noscript>
|
||||
Please enable JavaScript to view the comments powered by Remark.
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+3
-3
@@ -12016,9 +12016,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"prettier": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
|
||||
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"dev": true
|
||||
},
|
||||
"prettier-linter-helpers": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user