Add: rules page

This commit is contained in:
Jason 2018-10-06 00:37:22 +08:00
parent 56aef42c7e
commit 2be0eb448f
13 changed files with 615 additions and 18 deletions

43
package-lock.json generated
View File

@ -931,6 +931,15 @@
"@types/react-router": "4.0.31" "@types/react-router": "4.0.31"
} }
}, },
"@types/react-sortable-hoc": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@types/react-sortable-hoc/-/react-sortable-hoc-0.6.4.tgz",
"integrity": "sha512-SlUZ9Ek4UURWtpezoMlK2DdSR/XG0+8uTGkAao21pNWMYSoNUrRCOAtI6quohBAWIXMP1k2qfRVRsuizIYppXw==",
"dev": true,
"requires": {
"@types/react": "16.4.14"
}
},
"@webassemblyjs/ast": { "@webassemblyjs/ast": {
"version": "1.7.6", "version": "1.7.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.6.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.6.tgz",
@ -1543,6 +1552,15 @@
"util.promisify": "1.0.0" "util.promisify": "1.0.0"
} }
}, },
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"requires": {
"core-js": "2.5.7",
"regenerator-runtime": "0.11.1"
}
},
"bail": { "bail": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz",
@ -2580,6 +2598,11 @@
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
"dev": true "dev": true
}, },
"core-js": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
},
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@ -6097,6 +6120,11 @@
"is-cwebp-readable": "2.0.1" "is-cwebp-readable": "2.0.1"
} }
}, },
"immer": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
"integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
},
"import-cwd": { "import-cwd": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@ -9777,6 +9805,16 @@
"warning": "4.0.2" "warning": "4.0.2"
} }
}, },
"react-sortable-hoc": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-0.8.3.tgz",
"integrity": "sha512-vt2qQ9DnPLjGZ9osM2jBULdi7WfFXtYVuHvjHX8o2em7Rcla9FXIG60aWFbvvpFC1iXyATw5PWZX0B57EUOYfQ==",
"requires": {
"babel-runtime": "6.26.0",
"invariant": "2.2.4",
"prop-types": "15.6.2"
}
},
"read-all-stream": { "read-all-stream": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz", "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz",
@ -9881,6 +9919,11 @@
"regenerate": "1.4.0" "regenerate": "1.4.0"
} }
}, },
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
},
"regenerator-transform": { "regenerator-transform": {
"version": "0.13.3", "version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.3.tgz", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.3.tgz",

View File

@ -35,6 +35,7 @@
"@types/react-dom": "^16.0.7", "@types/react-dom": "^16.0.7",
"@types/react-i18next": "^7.8.2", "@types/react-i18next": "^7.8.2",
"@types/react-router-dom": "^4.3.1", "@types/react-router-dom": "^4.3.1",
"@types/react-sortable-hoc": "^0.6.4",
"autoprefixer": "^9.1.5", "autoprefixer": "^9.1.5",
"awesome-typescript-loader": "^5.2.1", "awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.0.2", "babel-loader": "^8.0.2",
@ -67,6 +68,7 @@
"dayjs": "^1.7.5", "dayjs": "^1.7.5",
"i18next": "^11.9.0", "i18next": "^11.9.0",
"i18next-browser-languagedetector": "^2.2.3", "i18next-browser-languagedetector": "^2.2.3",
"immer": "^1.7.2",
"ini": "^1.3.5", "ini": "^1.3.5",
"mobx": "^5.1.2", "mobx": "^5.1.2",
"mobx-react": "^5.2.8", "mobx-react": "^5.2.8",
@ -76,6 +78,7 @@
"react-dom": "^16.5.2", "react-dom": "^16.5.2",
"react-i18next": "^7.12.0", "react-i18next": "^7.12.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"react-sortable-hoc": "^0.8.3",
"typescript": "^3.0.3" "typescript": "^3.0.3"
} }
} }

View File

@ -1,27 +1,50 @@
import * as React from 'react' import * as React from 'react'
import { BaseComponentProps } from '@models/BaseProps' import { BaseComponentProps } from '@models/BaseProps'
// import classnames from 'classnames' import classnames from 'classnames'
import './style.scss' import './style.scss'
interface InputProps extends BaseComponentProps { interface InputProps extends BaseComponentProps {
value?: string | number value?: string | number
disabled?: boolean disabled?: boolean
align?: 'left' | 'center' | 'right'
inside?: boolean
autoFocus?: boolean
onChange?: (value: string, event?: React.ChangeEvent<HTMLInputElement>) => void onChange?: (value: string, event?: React.ChangeEvent<HTMLInputElement>) => void
onBlur?: (event?: React.FocusEvent<HTMLInputElement>) => void
} }
export class Input extends React.Component<InputProps, {}> { export class Input extends React.Component<InputProps, {}> {
static defaultProps: InputProps = { static defaultProps: InputProps = {
value: '', value: '',
disabled: false, disabled: false,
onChange: () => {} align: 'center',
inside: false,
autoFocus: false,
onChange: () => {},
onBlur: () => {}
} }
render () { render () {
const { onChange, value } = this.props const {
className,
style,
value,
align,
inside,
autoFocus,
onChange,
onBlur
} = this.props
return ( return (
<input className="input" onChange={(event) => { <input
onChange(event.target.value, event) className={classnames('input', `input-align-${align}`, { 'input-inside': inside }, className)}
}} value={value} ></input> style={style}
value={value}
autoFocus={autoFocus}
onChange={event => onChange(event.target.value, event)}
onBlur={onBlur}
/>
) )
} }
} }

View File

@ -1,21 +1,41 @@
@import '~@styles/variables'; @import '~@styles/variables';
$height: 30px;
.input { .input {
display: inline-block; display: inline-block;
height: 30px; height: $height;
width: 100%; width: 100%;
border-radius: 3px; padding: 0 10px;
border: 1px solid $color-primary-lightly;
padding: 4px 11px;
font-size: 14px; font-size: 14px;
color: $color-primary-darken; color: $color-primary-darken;
text-align: center; border-radius: 3px;
line-height: 1.5; border: 1px solid $color-primary-lightly;
line-height: $height;
transition: all 0.3s; transition: all 0.3s;
&:focus { &:focus {
outline: 0; outline: 0;
border-color: $color-primary; border-color: $color-primary;
color: $color-primary-dark;
box-shadow: 0 2px 5px rgba($color: $color-primary, $alpha: 0.5); box-shadow: 0 2px 5px rgba($color: $color-primary, $alpha: 0.5);
} }
} }
.input-align-left {
text-align: left;
}
.input-align-center {
text-align: center;
}
.input-align-right {
text-align: right;
}
.input-inside {
&:focus {
box-shadow: none;
}
}

View File

@ -16,6 +16,7 @@ $padding: 12px;
.column { .column {
padding: 0 $padding / 2; padding: 0 $padding / 2;
display: flex; display: flex;
flex-shrink: 0;
} }
@for $c from 1 through 24 { @for $c from 1 through 24 {

View File

@ -0,0 +1,166 @@
import * as React from 'react'
import classnames from 'classnames'
import { Icon } from '@components'
import { BaseComponentProps } from '@models'
import { createPortal } from 'react-dom'
import './style.scss'
type OptionValue = string | number
interface SelectProps extends BaseComponentProps {
/**
* selected value
* must match one of options
*/
value: OptionValue
onSelect?: (value: OptionValue, e: React.MouseEvent<HTMLLIElement>) => void
}
interface SelectState {
dropdownListStyles: React.CSSProperties
showDropDownList: boolean
}
export class Select extends React.Component<SelectProps, SelectState> {
// portal container
$container: Element
// drop down list
$attachment = React.createRef<HTMLDivElement>()
// target position element
$target = React.createRef<HTMLDivElement>()
state = {
dropdownListStyles: {},
showDropDownList: false
}
constructor (props) {
super(props)
// create container element
const container = document.createElement('div')
document.body.appendChild(container)
this.$container = container
document.addEventListener('click', this.handleGlobalClick, true)
}
componentDidMount () {
this.setState({ dropdownListStyles: this.calculateAttachmentPosition() })
}
componentWillUnmount () {
document.body.removeChild(this.$container)
document.removeEventListener('click', this.handleGlobalClick, true)
}
private handleGlobalClick = (e) => {
const el = this.$attachment.current
if (el && !el.contains(e.target)) {
this.setState({ showDropDownList: false })
}
}
private calculateAttachmentPosition () {
const targetRectInfo = this.$target.current.getBoundingClientRect()
return {
top: Math.floor(targetRectInfo.top) - 10,
left: Math.floor(targetRectInfo.left) - 10,
width: Math.floor(targetRectInfo.width)
}
}
private getSelectedOption = (value: OptionValue, children: React.ReactNode) => {
let matchChild: React.ReactElement<any> = null
React.Children.forEach(children, (child: React.ReactElement<any>) => {
if (child.props && child.props.value === value) {
matchChild = child
}
})
return matchChild
}
private hookChildren = (
children: React.ReactNode,
value: OptionValue,
onSelect: SelectProps['onSelect']
) => {
return React.Children.map(children, (child: React.ReactElement<any>) => {
if (!child.props || !child.type) {
return child
}
// add classname for selected option
const className = child.props.value === value
? classnames(child.props.className, 'selected')
: child.props.className
// hook element onclick event
const rawOnClickEvent = child.props.onClick
return React.cloneElement(child, Object.assign({}, child.props, {
onClick: (e: React.MouseEvent<HTMLLIElement>) => {
onSelect(child.props.value, e)
this.setState({ showDropDownList: false })
rawOnClickEvent && rawOnClickEvent(e)
},
className
}))
})
}
render () {
const { value, onSelect, children, className: cn, style } = this.props
const { dropdownListStyles, showDropDownList } = this.state
const matchChild = this.getSelectedOption(value, children)
const dropDownList = (
<div
className={classnames('select-list', { 'select-list-show': showDropDownList })}
ref={this.$attachment}
style={dropdownListStyles}
>
<ul className="list">
{this.hookChildren(children, value, onSelect)}
</ul>
</div>
)
return <>
<div
className={classnames('select', cn)}
style={style}
ref={this.$target}
onClick={() => this.setState({ showDropDownList: !showDropDownList })}
>
{matchChild.props.children}
<Icon type="triangle-down" />
</div>
{createPortal(dropDownList, this.$container)}
</>
}
}
interface OptionProps extends BaseComponentProps {
key: React.Key
value: OptionValue
disabled?: boolean
onClick?: (e: React.MouseEvent<HTMLLIElement>) => void
}
export class Option extends React.Component<OptionProps, {}> {
render () {
const { className: cn, style, key, disabled = false, children, onClick = () => {} } = this.props
const className = classnames('option', { disabled }, cn)
return (
<li className={className} style={style} key={key} onClick={onClick}>{children}</li>
)
}
}

View File

@ -0,0 +1,65 @@
@import '~@styles/variables';
.select {
flex: 1;
cursor: pointer;
font-size: 14px;
line-height: 30px;
color: $color-primary-darken;
display: flex;
overflow: hidden;
> i {
margin-left: 5px;
color: $color-primary-darken;
}
}
.select-list {
position: fixed;
max-width: 170px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
opacity: 0.8;
pointer-events: none;
transform: scaleY(0);
transform-origin: top;
transition: all 200ms linear;
.list {
max-height: 300px;
overflow: auto;
background: $color-white;
padding: 5px 0;
transform: scaleY(2);
transform-origin: top;
transition: all 200ms linear;
> .option {
color: $color-primary-darken;
padding: 10px 15px;
font-size: 14px;
list-style: none;
cursor: pointer;
&:hover {
background: rgba($color: $color-primary-lightly, $alpha: 0.5);
}
}
> .selected {
background: rgba($color: $color-primary-lightly, $alpha: 0.5);
}
}
}
.select-list-show {
opacity: 1;
pointer-events: visible;
transform: scaleY(1);
.list {
transform: scaleY(1);
}
}

View File

@ -24,10 +24,12 @@ $width: 32px;
} }
&.disabled { &.disabled {
cursor: not-allowed;
background-color: $color-gray-dark; background-color: $color-gray-dark;
&::after { &::after {
background-color: #f6f6f6; background-color: #f6f6f6;
box-shadow: 0 0 8px rgba($color-gray-darken, 0.5);
} }
} }

View File

@ -7,3 +7,4 @@ export * from './Col'
export * from './ButtonSelect' export * from './ButtonSelect'
export * from './Tags' export * from './Tags'
export * from './Input' export * from './Input'
export * from './Select'

View File

@ -1,15 +1,184 @@
import * as React from 'react' import * as React from 'react'
import { Header } from '@components' import produce from 'immer'
import { translate } from 'react-i18next' import { translate } from 'react-i18next'
import { I18nProps } from '@models' import { SortableContainer, SortableElement, SortableHandle, arrayMove } from 'react-sortable-hoc'
import { Header, Icon, Card, Row, Col, Select, Option, Input } from '@components'
import { I18nProps, RuleType } from '@models'
import './style.scss'
interface Rule {
type: RuleType
payload: string
proxy: string
}
interface RulesState {
rules: Rule[]
proxies: { [key: string]: { type: string } }
modifiedIndex: number
}
class Rules extends React.Component<I18nProps, RulesState> {
state = {
rules: [
{ type: RuleType['DOMAIN-SUFFIX'], payload: 'google.com.hk', proxy: 'HK' },
{ type: RuleType['DOMAIN-SUFFIX'], payload: 'twitter.com', proxy: 'HKG' },
{ type: RuleType['DOMAIN'], payload: 'pornhub.com', proxy: 'HKG' },
{ type: RuleType['FINAL'], payload: '', proxy: 'DIRECT' }
],
proxies: {
DIRECT: { type: 'Direct' },
GLOBAL: { type: 'Selector' },
HKG: { type: 'URLTest' },
HK: { type: 'Shadowsocks' },
SGGGGGGGGGG: { type: 'Vmess' },
REJECT: { type: 'Reject' }
},
modifiedIndex: -1
}
private handleModifyType = (index, type) => {
const { rules } = this.state
this.setState({
rules: produce(rules, draftState => {
draftState[index].type = type
})
})
}
private handleModifyPayload = (index, payload) => {
const { rules } = this.state
this.setState({
rules: produce(rules, draftState => {
draftState[index].payload = payload
})
})
}
private handleModifyProxy = (index, proxy) => {
const { rules } = this.state
this.setState({
rules: produce(rules, draftState => {
draftState[index].proxy = proxy
})
})
}
onSortEnd = ({ oldIndex, newIndex }) => {
this.setState({
rules: arrayMove(this.state.rules, oldIndex, newIndex)
})
}
renderRules = ({ rules }) => {
const SortableItem = SortableElement<{ rule: Rule, idx: number }>(itemProps => {
const { rule, idx } = itemProps
return this.renderRuleItem(rule, idx)
})
return <ul>
{
rules.map((rule: Rule, idx: number) => {
const isFinal = rule.type === 'FINAL'
return <SortableItem key={idx} index={idx} idx={idx} rule={rule} disabled={isFinal} />
})
}
</ul>
}
renderRuleItem = (rule: Rule, index) => {
const { modifiedIndex, proxies } = this.state
const isFinal = rule.type === 'FINAL'
const DragHandle = SortableHandle(() => <Icon type="drag" size={16} />)
return (
<li className="rule-item" key={index}>
<Row className="rule-item-row" gutter={24} align="middle">
<Col className="drag-handler" span={1}>
{!isFinal && <DragHandle />}
</Col>
<Col className="rule-type" span={5}>
{
isFinal
? rule.type
: (
<Select value={rule.type} onSelect={type => this.handleModifyType(index, type)}>
{
Object.keys(RuleType)
.filter(type => type !== 'FINAL')
.map(typeName => {
const type = RuleType[typeName]
return (
<Option value={type} key={type}>{type}</Option>
)
})
}
</Select>
)
}
</Col>
<Col className="payload" span={8}>
{
modifiedIndex === index
? (
<Input
value={rule.payload}
align="left"
inside={true}
autoFocus={true}
onChange={ value => this.handleModifyPayload(index, value) }
onBlur={() => this.setState({ modifiedIndex: -1 })}
style={{ maxWidth: 230 }}
/>
)
: <span onClick={() => this.setState({
modifiedIndex: index
})}>{rule.payload}</span>
}
</Col>
<Col className="rule-proxy" span={5}>
<Select className="rule-proxy-option" value={rule.proxy} onSelect={proxy => this.handleModifyProxy(index, proxy)}>
{
Object.keys(proxies).map(proxyName => {
const proxy = proxies[proxyName]
return (
<Option className="rule-proxy-option" value={proxyName} key={`${proxyName}-${proxy.type}`}>
<span className="label">{proxy.type}</span>
<span className="value">{proxyName}</span>
</Option>
)
})
}
</Select>
</Col>
<Col className="delete-btn" span={2} offset={3}>
{!isFinal && <span></span>}
</Col>
</Row>
</li>
)
}
class Rules extends React.Component<I18nProps, {}> {
render () { render () {
const { t } = this.props const { t } = this.props
const { rules } = this.state
const SortableList = SortableContainer<{ rules: Rule[] }>(this.renderRules)
return ( return (
<div className="page"> <div className="page">
<Header title={t('title')} /> <Header title={t('title')} >
<Icon type="plus" size={20} style={{ fontWeight: 'bold', cursor: 'pointer' }} />
</Header>
<Card className="rules-card">
<div className="rules">
<SortableList rules={rules} onSortEnd={this.onSortEnd} useDragHandle={true} />
</div>
</Card>
</div> </div>
) )
} }

View File

@ -0,0 +1,96 @@
@import '~@styles/variables';
.rules-card {
margin-top: 25px;
padding: 0;
}
.rule-item {
line-height: 30px;
padding: 5px 0;
list-style: none;
user-select: none;
border-bottom: 1px solid rgba($color: $color-primary-lightly, $alpha: 0.5);
.rule-item-row {
padding: 5px 0;
}
.drag-handler {
cursor: row-resize;
margin: 0 10px;
display: flex;
justify-content: center;
> i {
font-weight: bold;
color: $color-gray-dark;
}
}
.rule-type {
font-size: 14px;
color: $color-primary-darken;
> i {
margin-left: 5px;
color: $color-primary-darken;
}
}
.payload {
font-size: 14px;
color: $color-primary-darken;
cursor: pointer;
}
.rule-proxy {
font-size: 14px;
color: $color-primary-darken;
}
.delete-btn {
opacity: 0;
transition: all 300ms ease;
span {
font-size: 14px;
color: $color-red;
cursor: pointer;
}
}
&:last-child {
border-bottom: none;
}
&:hover {
.delete-btn {
opacity: 1;
}
}
}
.rule-proxy-option {
display: flex;
align-items: center;
overflow: hidden;
.label {
margin-right: 5px;
height: 20px;
line-height: 20px;
padding: 0 8px;
font-size: 10px;
border-radius: 10px;
color: $color-white;
background: $color-gray-dark;
}
.value {
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@ -1,9 +1,17 @@
export interface Rule { export interface Rule {
type?: 'DOMAIN' | 'DOMAIN-SUFFIX' | 'DOMAIN-KEYWORD' | 'DOMAIN-SUFFIX' | 'GEOIP' | 'FINAL' type?: RuleType
value?: string value?: string
use?: string // proxy or proxy group name use?: string // proxy or proxy group name
} }
export enum RuleType {
DOMAIN = 'DOMAIN',
'DOMAIN-SUFFIX' = 'DOMAIN-SUFFIX',
'DOMAIN-KEYWORD' = 'DOMAIN-KEYWORD',
'GEOIP' = 'GEOIP',
'FINAL' = 'FINAL'
}

View File

@ -18,12 +18,12 @@ html {
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
} }
.app { .app {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
} }
.app.clash-x { .app.clash-x {