diff --git a/package-lock.json b/package-lock.json index c605f7d..55545ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -931,6 +931,15 @@ "@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": { "version": "1.7.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.6.tgz", @@ -1543,6 +1552,15 @@ "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": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz", @@ -2580,6 +2598,11 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "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": { "version": "1.0.2", "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" } }, + "immer": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz", + "integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA==" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", @@ -9777,6 +9805,16 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz", @@ -9881,6 +9919,11 @@ "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": { "version": "0.13.3", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.3.tgz", diff --git a/package.json b/package.json index 484a11f..5c8ef25 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/react-dom": "^16.0.7", "@types/react-i18next": "^7.8.2", "@types/react-router-dom": "^4.3.1", + "@types/react-sortable-hoc": "^0.6.4", "autoprefixer": "^9.1.5", "awesome-typescript-loader": "^5.2.1", "babel-loader": "^8.0.2", @@ -67,6 +68,7 @@ "dayjs": "^1.7.5", "i18next": "^11.9.0", "i18next-browser-languagedetector": "^2.2.3", + "immer": "^1.7.2", "ini": "^1.3.5", "mobx": "^5.1.2", "mobx-react": "^5.2.8", @@ -76,6 +78,7 @@ "react-dom": "^16.5.2", "react-i18next": "^7.12.0", "react-router-dom": "^4.3.1", + "react-sortable-hoc": "^0.8.3", "typescript": "^3.0.3" } } diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index f7116ce..dad1daa 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,27 +1,50 @@ import * as React from 'react' import { BaseComponentProps } from '@models/BaseProps' -// import classnames from 'classnames' +import classnames from 'classnames' import './style.scss' interface InputProps extends BaseComponentProps { value?: string | number disabled?: boolean + align?: 'left' | 'center' | 'right' + inside?: boolean + autoFocus?: boolean onChange?: (value: string, event?: React.ChangeEvent) => void + onBlur?: (event?: React.FocusEvent) => void } export class Input extends React.Component { static defaultProps: InputProps = { value: '', disabled: false, - onChange: () => {} + align: 'center', + inside: false, + autoFocus: false, + onChange: () => {}, + onBlur: () => {} } render () { - const { onChange, value } = this.props + const { + className, + style, + value, + align, + inside, + autoFocus, + onChange, + onBlur + } = this.props + return ( - { - onChange(event.target.value, event) - }} value={value} > + onChange(event.target.value, event)} + onBlur={onBlur} + /> ) } } diff --git a/src/components/Input/style.scss b/src/components/Input/style.scss index ab23239..394a87a 100644 --- a/src/components/Input/style.scss +++ b/src/components/Input/style.scss @@ -1,21 +1,41 @@ @import '~@styles/variables'; +$height: 30px; + .input { display: inline-block; - height: 30px; + height: $height; width: 100%; - border-radius: 3px; - border: 1px solid $color-primary-lightly; - padding: 4px 11px; + padding: 0 10px; font-size: 14px; color: $color-primary-darken; - text-align: center; - line-height: 1.5; + border-radius: 3px; + border: 1px solid $color-primary-lightly; + line-height: $height; transition: all 0.3s; &:focus { outline: 0; border-color: $color-primary; + color: $color-primary-dark; 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; + } +} diff --git a/src/components/Row/style.scss b/src/components/Row/style.scss index 8611822..49221a4 100644 --- a/src/components/Row/style.scss +++ b/src/components/Row/style.scss @@ -16,6 +16,7 @@ $padding: 12px; .column { padding: 0 $padding / 2; display: flex; + flex-shrink: 0; } @for $c from 1 through 24 { diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000..60f9f0d --- /dev/null +++ b/src/components/Select/index.tsx @@ -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) => void +} + +interface SelectState { + dropdownListStyles: React.CSSProperties + showDropDownList: boolean +} + +export class Select extends React.Component { + + // portal container + $container: Element + + // drop down list + $attachment = React.createRef() + + // target position element + $target = React.createRef() + + 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 = null + + React.Children.forEach(children, (child: React.ReactElement) => { + 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) => { + 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) => { + 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 = ( +
+
    + {this.hookChildren(children, value, onSelect)} +
+
+ ) + + return <> +
this.setState({ showDropDownList: !showDropDownList })} + > + {matchChild.props.children} + +
+ {createPortal(dropDownList, this.$container)} + + } +} + +interface OptionProps extends BaseComponentProps { + key: React.Key + value: OptionValue + disabled?: boolean + onClick?: (e: React.MouseEvent) => void +} + +export class Option extends React.Component { + render () { + const { className: cn, style, key, disabled = false, children, onClick = () => {} } = this.props + const className = classnames('option', { disabled }, cn) + + return ( +
  • {children}
  • + ) + } +} diff --git a/src/components/Select/style.scss b/src/components/Select/style.scss new file mode 100644 index 0000000..7085cd0 --- /dev/null +++ b/src/components/Select/style.scss @@ -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); + } +} diff --git a/src/components/Switch/style.scss b/src/components/Switch/style.scss index 5d95e76..fc6c910 100644 --- a/src/components/Switch/style.scss +++ b/src/components/Switch/style.scss @@ -24,10 +24,12 @@ $width: 32px; } &.disabled { + cursor: not-allowed; background-color: $color-gray-dark; &::after { background-color: #f6f6f6; + box-shadow: 0 0 8px rgba($color-gray-darken, 0.5); } } diff --git a/src/components/index.ts b/src/components/index.ts index 8d504d9..ec567de 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,3 +7,4 @@ export * from './Col' export * from './ButtonSelect' export * from './Tags' export * from './Input' +export * from './Select' diff --git a/src/containers/Rules/index.tsx b/src/containers/Rules/index.tsx index 2f613f1..bad95e0 100644 --- a/src/containers/Rules/index.tsx +++ b/src/containers/Rules/index.tsx @@ -1,15 +1,184 @@ import * as React from 'react' -import { Header } from '@components' +import produce from 'immer' 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 { + + 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
      + { + rules.map((rule: Rule, idx: number) => { + const isFinal = rule.type === 'FINAL' + return + }) + } +
    + } + + renderRuleItem = (rule: Rule, index) => { + const { modifiedIndex, proxies } = this.state + const isFinal = rule.type === 'FINAL' + const DragHandle = SortableHandle(() => ) + + return ( +
  • + + + {!isFinal && } + + + { + isFinal + ? rule.type + : ( + + ) + } + + + { + modifiedIndex === index + ? ( + this.handleModifyPayload(index, value) } + onBlur={() => this.setState({ modifiedIndex: -1 })} + style={{ maxWidth: 230 }} + /> + ) + : this.setState({ + modifiedIndex: index + })}>{rule.payload} + } + + + + + + {!isFinal && 删除} + + +
  • + ) + } -class Rules extends React.Component { render () { const { t } = this.props + const { rules } = this.state + const SortableList = SortableContainer<{ rules: Rule[] }>(this.renderRules) return (
    -
    +
    + +
    + + +
    + +
    +
    ) } diff --git a/src/containers/Rules/style.scss b/src/containers/Rules/style.scss new file mode 100644 index 0000000..03c783a --- /dev/null +++ b/src/containers/Rules/style.scss @@ -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; + } +} diff --git a/src/models/Rule.ts b/src/models/Rule.ts index c577670..4779306 100644 --- a/src/models/Rule.ts +++ b/src/models/Rule.ts @@ -1,9 +1,17 @@ export interface Rule { - type?: 'DOMAIN' | 'DOMAIN-SUFFIX' | 'DOMAIN-KEYWORD' | 'DOMAIN-SUFFIX' | 'GEOIP' | 'FINAL' + type?: RuleType value?: string use?: string // proxy or proxy group name } + +export enum RuleType { + DOMAIN = 'DOMAIN', + 'DOMAIN-SUFFIX' = 'DOMAIN-SUFFIX', + 'DOMAIN-KEYWORD' = 'DOMAIN-KEYWORD', + 'GEOIP' = 'GEOIP', + 'FINAL' = 'FINAL' +} diff --git a/src/styles/common.scss b/src/styles/common.scss index 6e82100..e45b5dc 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -18,12 +18,12 @@ html { body { margin: 0; padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } .app { display: flex; 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 {