feat: allow change proxies sorting in group

This commit is contained in:
Haishan 2020-04-26 17:35:03 +08:00
parent 7cdbba5bf4
commit 94e2b1e398
25 changed files with 1072 additions and 336 deletions

View file

@ -1,24 +1,7 @@
---
env:
browser: true
node: true
jest/globals: true
es6: true
parser: babel-eslint
plugins:
- import
- react-hooks
- jest
extends:
- react-app
- eslint:recommended
- plugin:import/errors
settings:
import/resolver: webpack
globals:
__DEV__: true

View file

@ -2,7 +2,6 @@
"name": "yacd",
"version": "0.1.11",
"description": "Yet another Clash dashboard",
"main": "index.js",
"scripts": {
"lint": "eslint --cache src",
"start": "NODE_ENV=development node server.js",
@ -26,7 +25,8 @@
"not op_mini all"
],
"keywords": [
"react"
"react",
"clash"
],
"author": "Haishan <haishanhan@gmail.com> (https://haishan.me)",
"private": true,
@ -71,23 +71,30 @@
"@babel/preset-flow": "^7.7.4",
"@babel/preset-react": "^7.7.4",
"@hsjs/react-refresh-webpack-plugin": "^0.1.3",
"@pmmmwh/react-refresh-webpack-plugin": "^0.2.0",
"@types/jest": "^25.2.1",
"@types/react": "^16.9.34",
"@typescript-eslint/eslint-plugin": "2.x",
"@typescript-eslint/parser": "2.x",
"autoprefixer": "^9.7.3",
"babel-eslint": "^10.0.3",
"babel-eslint": "10.x",
"babel-loader": "^8.0.5",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"cssnano": "^4.1.7",
"eslint": "^6.8.0",
"eslint-config-react-app": "^5.0.2",
"eslint": "6.x",
"eslint-config-react-app": "^5.2.1",
"eslint-import-resolver-webpack": "^0.12.0",
"eslint-plugin-flowtype": "^4.6.0",
"eslint-plugin-import": "^2.18.0",
"eslint-plugin-flowtype": "4.x",
"eslint-plugin-import": "2.x",
"eslint-plugin-jest": "^23.6.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^3.0.0",
"eslint-plugin-jsx-a11y": "6.x",
"eslint-plugin-react": "7.x",
"eslint-plugin-react-hooks": "2.x",
"file-loader": "^6.0.0",
"fork-ts-checker-notifier-webpack-plugin": "^2.0.0",
"fork-ts-checker-webpack-plugin": "^4.1.3",
"html-webpack-plugin": "^4.2.0",
"husky": "^4.0.0",
"lint-staged": "^10.0.7",
@ -99,10 +106,12 @@
"postcss-nested": "^4.2.0",
"postcss-simple-vars": "^5.0.2",
"prettier": "^2.0.4",
"react-refresh": "0.0.0-experimental-241c4467e",
"react-refresh": "^0.8.1",
"resize-observer-polyfill": "^1.5.1",
"style-loader": "^1.1.2",
"terser-webpack-plugin": "^2.3.1",
"ts-loader": "^7.0.1",
"typescript": "^3.8.3",
"webpack": "^4.41.6",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",

View file

@ -10,7 +10,7 @@
.content {
outline: none;
position: absolute;
position: relative;
color: #ddd;
top: 50%;
left: 50%;

View file

@ -6,7 +6,7 @@
color: var(--color-text);
max-width: 300px;
line-height: 1.4;
transform: translate(-50%, -50%) scale(1.5);
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.6;
transition: all 0.3s ease;
}

View file

@ -1,39 +1,34 @@
import React from 'react';
import { connect, useStoreActions } from './StateProvider';
import { connect } from './StateProvider';
import Button from './Button';
import ContentHeader from './ContentHeader';
import ProxyGroup from './ProxyGroup';
import { Zap, Filter, Circle } from 'react-feather';
import BaseModal from './shared/BaseModal';
import Settings from './proxies/Settings';
import Equalizer from './svg/Equalizer';
import { Zap } from 'react-feather';
import ProxyProviderList from './ProxyProviderList';
import { Fab, Action } from 'react-tiny-fab';
import { Fab } from 'react-tiny-fab';
import './rtf.css';
import s0 from './Proxies.module.css';
import {
getDelay,
getRtFilterSwitch,
getProxyGroupNames,
getProxyProviders,
fetchProxies,
requestDelayAll
requestDelayAll,
} from '../store/proxies';
import { getClashAPIConfig } from '../store/app';
const { useEffect, useCallback, useRef } = React;
const { useState, useEffect, useCallback, useRef } = React;
function Proxies({
dispatch,
groupNames,
delay,
proxyProviders,
apiConfig,
filterZeroRT
}) {
function Proxies({ dispatch, groupNames, delay, proxyProviders, apiConfig }) {
const refFetchedTimestamp = useRef({});
const { toggleUnavailableProxiesFilter } = useStoreActions();
const requestDelayAllFn = useCallback(
() => dispatch(requestDelayAll(apiConfig)),
[apiConfig, dispatch]
@ -62,11 +57,27 @@ function Proxies({
return () => window.removeEventListener('focus', fn, false);
}, [fetchProxiesHooked]);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const closeSettingsModal = useCallback(() => {
setIsSettingsModalOpen(false);
}, []);
return (
<>
<div className={s0.topBar}>
<Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}>
<Equalizer size={16} />
</Button>
</div>
<BaseModal
isOpen={isSettingsModalOpen}
onRequestClose={closeSettingsModal}
>
<Settings />
</BaseModal>
<ContentHeader title="Proxies" />
<div>
{groupNames.map(groupName => {
{groupNames.map((groupName) => {
return (
<div className={s0.group} key={groupName}>
<ProxyGroup
@ -81,27 +92,20 @@ function Proxies({
</div>
<ProxyProviderList items={proxyProviders} />
<div style={{ height: 60 }} />
<Fab icon={<Circle />}>
<Action text="Test Latency" onClick={requestDelayAllFn}>
<Zap width={16} />
</Action>
<Action
text={(filterZeroRT ? 'Show' : 'Hide') + ' Unavailable Proxies'}
onClick={toggleUnavailableProxiesFilter}
>
<Filter width={16} />
</Action>
</Fab>
<Fab
icon={<Zap width={16} />}
onClick={requestDelayAllFn}
text="Test Latency"
></Fab>
</>
);
}
const mapState = s => ({
const mapState = (s) => ({
apiConfig: getClashAPIConfig(s),
groupNames: getProxyGroupNames(s),
proxyProviders: getProxyProviders(s),
delay: getDelay(s),
filterZeroRT: getRtFilterSwitch(s)
});
export default connect(mapState)(Proxies);

View file

@ -1,3 +1,13 @@
.topBar {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-background);
display: flex;
justify-content: flex-end;
padding: 5px 5px 2px 0;
}
.group {
padding: 10px 15px;
@media (--breakpoint-not-small) {

View file

@ -3,8 +3,12 @@ import cx from 'classnames';
import memoizeOne from 'memoize-one';
import { connect, useStoreActions } from './StateProvider';
import { getProxies, getRtFilterSwitch } from '../store/proxies';
import { getCollapsibleIsOpen } from '../store/app';
import { getProxies } from '../store/proxies';
import {
getCollapsibleIsOpen,
getProxySortBy,
getHideUnavailableProxies,
} from '../store/app';
import CollapsibleSectionHeader from './CollapsibleSectionHeader';
import Proxy, { ProxySmall } from './Proxy';
@ -18,7 +22,7 @@ function ProxyGroup({ name, all, type, now, isOpen, apiConfig, dispatch }) {
const isSelectable = useMemo(() => type === 'Selector', [type]);
const {
app: { updateCollapsibleIsOpen }
app: { updateCollapsibleIsOpen },
} = useStoreActions();
const toggle = useCallback(() => {
@ -26,7 +30,7 @@ function ProxyGroup({ name, all, type, now, isOpen, apiConfig, dispatch }) {
}, [isOpen, updateCollapsibleIsOpen, name]);
const itemOnTapCallback = useCallback(
proxyName => {
(proxyName) => {
if (!isSelectable) return;
dispatch(switchProxy(apiConfig, name, proxyName));
},
@ -60,23 +64,23 @@ type ProxyListProps = {
all: string[],
now?: string,
isSelectable?: boolean,
itemOnTapCallback?: string => void,
show?: boolean
itemOnTapCallback?: (string) => void,
show?: boolean,
};
export function ProxyList({
all,
now,
isSelectable,
itemOnTapCallback,
sortedAll
sortedAll,
}: ProxyListProps) {
const proxies = sortedAll || all;
return (
<div className={s0.list}>
{proxies.map(proxyName => {
{proxies.map((proxyName) => {
const proxyClassName = cx(s0.proxy, {
[s0.proxySelectable]: isSelectable
[s0.proxySelectable]: isSelectable,
});
return (
<div
@ -107,7 +111,7 @@ const getSortDelay = (d, w) => {
};
function filterAvailableProxies(list, delay) {
return list.filter(name => {
return list.filter((name) => {
const d = delay[name];
if (d === undefined) {
return true;
@ -120,19 +124,50 @@ function filterAvailableProxies(list, delay) {
});
}
function filterAvailableProxiesAndSortImpl(all, delay, filterByRt) {
const ProxySortingFns = {
Natural: (proxies, _delay) => {
return proxies;
},
LatencyAsc: (proxies, delay) => {
return proxies.sort((a, b) => {
const d1 = getSortDelay(delay[a], 999999);
const d2 = getSortDelay(delay[b], 999999);
return d1 - d2;
});
},
LatencyDesc: (proxies, delay) => {
return proxies.sort((a, b) => {
const d1 = getSortDelay(delay[a], 999999);
const d2 = getSortDelay(delay[b], 999999);
return d2 - d1;
});
},
NameAsc: (proxies) => {
return proxies.sort();
},
NameDesc: (proxies) => {
return proxies.sort((a, b) => {
if (a > b) return -1;
if (a < b) return 1;
return 0;
});
},
};
function filterAvailableProxiesAndSortImpl(
all,
delay,
hideUnavailableProxies,
proxySortBy
) {
// all is freezed
let filtered = [...all];
if (filterByRt) {
if (hideUnavailableProxies) {
filtered = filterAvailableProxies(all, delay);
}
return filtered.sort((first, second) => {
const d1 = getSortDelay(delay[first], 999999);
const d2 = getSortDelay(delay[second], 999999);
return d1 - d2;
});
return ProxySortingFns[proxySortBy](filtered, delay);
}
export const filterAvailableProxiesAndSort = memoizeOne(
filterAvailableProxiesAndSortImpl
);
@ -141,13 +176,13 @@ export function ProxyListSummaryView({
all,
now,
isSelectable,
itemOnTapCallback
itemOnTapCallback,
}: ProxyListProps) {
return (
<div className={s0.list}>
{all.map(proxyName => {
{all.map((proxyName) => {
const proxyClassName = cx(s0.proxy, {
[s0.proxySelectable]: isSelectable
[s0.proxySelectable]: isSelectable,
});
return (
<div
@ -168,14 +203,21 @@ export function ProxyListSummaryView({
export default connect((s, { name, delay }) => {
const proxies = getProxies(s);
const filterByRt = getRtFilterSwitch(s);
const collapsibleIsOpen = getCollapsibleIsOpen(s);
const proxySortBy = getProxySortBy(s);
const hideUnavailableProxies = getHideUnavailableProxies(s);
const group = proxies[name];
const { all, type, now } = group;
return {
all: filterAvailableProxiesAndSort(all, delay, filterByRt),
all: filterAvailableProxiesAndSort(
all,
delay,
hideUnavailableProxies,
proxySortBy
),
type,
now,
isOpen: collapsibleIsOpen[`proxyGroup:${name}`]
isOpen: collapsibleIsOpen[`proxyGroup:${name}`],
};
})(ProxyGroup);

View file

@ -9,16 +9,20 @@ import CollapsibleSectionHeader from './CollapsibleSectionHeader';
import {
ProxyList,
ProxyListSummaryView,
filterAvailableProxiesAndSort
filterAvailableProxiesAndSort,
} from './ProxyGroup';
import Button from './Button';
import { getClashAPIConfig, getCollapsibleIsOpen } from '../store/app';
import {
getClashAPIConfig,
getCollapsibleIsOpen,
getProxySortBy,
getHideUnavailableProxies,
} from '../store/app';
import {
getDelay,
getRtFilterSwitch,
updateProviderByName,
healthcheckProviderByName
healthcheckProviderByName,
} from '../store/proxies';
import s from './ProxyProvider.module.css';
@ -31,8 +35,8 @@ type Props = {
type: 'Proxy' | 'Rule',
vehicleType: 'HTTP' | 'File' | 'Compatible',
updatedAt?: string,
dispatch: any => void,
isOpen: boolean
dispatch: (any) => void,
isOpen: boolean,
};
function ProxyProvider({
@ -42,7 +46,7 @@ function ProxyProvider({
updatedAt,
isOpen,
dispatch,
apiConfig
apiConfig,
}: Props) {
const [isHealthcheckLoading, setIsHealthcheckLoading] = useState(false);
const updateProvider = useCallback(
@ -56,7 +60,7 @@ function ProxyProvider({
}, [apiConfig, dispatch, name, setIsHealthcheckLoading]);
const {
app: { updateCollapsibleIsOpen }
app: { updateCollapsibleIsOpen },
} = useStoreActions();
// const [isCollapsibleOpen, setCollapsibleOpen] = useState(false);
@ -101,11 +105,11 @@ function ProxyProvider({
const button = {
rest: { scale: 1 },
// hover: { scale: 1.1 },
pressed: { scale: 0.95 }
pressed: { scale: 0.95 },
};
const arrow = {
rest: { rotate: 0 },
hover: { rotate: 360, transition: { duration: 0.3 } }
hover: { rotate: 360, transition: { duration: 0.3 } },
};
function Refresh() {
return (
@ -124,18 +128,23 @@ function Refresh() {
}
const mapState = (s, { proxies, name }) => {
const filterByRt = getRtFilterSwitch(s);
const hideUnavailableProxies = getHideUnavailableProxies(s);
const delay = getDelay(s);
const collapsibleIsOpen = getCollapsibleIsOpen(s);
const apiConfig = getClashAPIConfig(s);
const proxySortBy = getProxySortBy(s);
return {
apiConfig,
proxies: filterAvailableProxiesAndSort(proxies, delay, filterByRt),
isOpen: collapsibleIsOpen[`proxyProvider:${name}`]
proxies: filterAvailableProxiesAndSort(
proxies,
delay,
hideUnavailableProxies,
proxySortBy
),
isOpen: collapsibleIsOpen[`proxyProvider:${name}`],
};
};
// const mapState = s => ({
// apiConfig: getClashAPIConfig(s)
// });
export default connect(mapState)(ProxyProvider);

View file

@ -1,15 +1,3 @@
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: local('Roboto Mono'), local('RobotoMono-Regular'),
url('https://cdn.jsdelivr.net/npm/@hsjs/fonts@0.0.1/robotomono/v5/L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2')
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
.relative {
position: relative;
}
@ -88,6 +76,7 @@ body.dark {
--color-background: #202020;
--color-text: #ddd;
--color-text-secondary: #ccc;
--color-text-highlight: #fff;
--color-bg-sidebar: #2d2d30;
--color-sb-active-row-bg: #494b4e;
--color-input-bg: #2d2d30;
@ -95,18 +84,22 @@ body.dark {
--color-toggle-bg: #353535;
--color-toggle-selected: #181818;
--color-icon: #c7c7c7;
--color-separator: #333;
--color-btn-bg: #232323;
--color-btn-fg: #bebebe;
--color-bg-proxy: #303030;
--color-row-odd: #282828;
--bg-modal: #1f1f20;
--bg-near-transparent: rgba(255, 255, 255, 0.1);
--select-border-color: #040404;
--select-bg-hover: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23ffffff%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23ffffff%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20);
}
body.light {
--color-background: #fbfbfb;
--color-text: #222;
--color-text-secondary: #646464;
--color-text-highlight: #040404;
--color-bg-sidebar: #e7e7e7;
--color-sb-active-row-bg: #d0d0d0;
--color-input-bg: #ffffff;
@ -114,12 +107,15 @@ body.light {
--color-toggle-bg: #ffffff;
--color-toggle-selected: #d7d7d7;
--color-icon: #5b5b5b;
--color-separator: #ccc;
--color-btn-bg: #f4f4f4;
--color-btn-fg: #101010;
--color-bg-proxy: #e7e7e7;
--color-row-odd: #f5f5f5;
--bg-modal: #fbfbfb;
--bg-near-transparent: rgba(0, 0, 0, 0.1);
--select-border-color: #999999;
--select-bg-hover: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23222222%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23222222%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20);
}
.flexCenter {

View file

@ -0,0 +1,75 @@
import * as React from 'react';
import { getProxySortBy, getHideUnavailableProxies } from '../../store/app';
import Switch from '../SwitchThemed';
import { connect, useStoreActions } from '../StateProvider';
import Select from '../shared/Select';
import s from './Settings.module.css';
const options = [
['Natural', 'Original order in config file'],
['LatencyAsc', 'By latency from small to big'],
['LatencyDesc', 'By latency from big to small'],
['NameAsc', 'By name alphabetically (A-Z)'],
['NameDesc', 'By name alphabetically (Z-A)'],
];
const { useCallback } = React;
function Settings({ appConfig }) {
const {
app: { updateAppConfig },
} = useStoreActions();
const handleProxySortByOnChange = useCallback(
(e) => {
updateAppConfig('proxySortBy', e.target.value);
},
[updateAppConfig]
);
const handleHideUnavailablesSwitchOnChange = useCallback(
(v) => {
updateAppConfig('hideUnavailableProxies', v);
},
[updateAppConfig]
);
return (
<>
<div className={s.labeledInput}>
<span>Sorting in group</span>
<div>
<Select
options={options}
selected={appConfig.proxySortBy}
onChange={handleProxySortByOnChange}
/>
</div>
</div>
<hr />
<div className={s.labeledInput}>
<span>Hide unavailable proxies</span>
<div>
<Switch
name="hideUnavailableProxies"
checked={appConfig.hideUnavailableProxies}
onChange={handleHideUnavailablesSwitchOnChange}
/>
</div>
</div>
</>
);
}
const mapState = (s) => {
const proxySortBy = getProxySortBy(s);
const hideUnavailableProxies = getHideUnavailableProxies(s);
return {
appConfig: {
proxySortBy,
hideUnavailableProxies,
},
};
};
export default connect(mapState)(Settings);

View file

@ -0,0 +1,17 @@
.labeledInput {
max-width: 85vw;
width: 400px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
padding: 13px 0;
}
hr {
height: 1px;
background-color: var(--color-separator);
border: none;
outline: none;
margin: 1rem 0px;
}

View file

@ -13,7 +13,7 @@
}
.rtf.open .rtf--mb > * {
transform-origin: center center;
transform: rotate(315deg);
transform: rotate(360deg);
transition: ease-in-out transform 0.2s;
}
.rtf.open .rtf--mb > ul {
@ -107,18 +107,15 @@
}
.rtf--mb {
height: 56px;
width: 56px;
height: 48px;
width: 48px;
z-index: 9999;
/* background-color: #666666; */
background: #387cec;
/* background: var(--color-btn-bg); */
display: inline-flex;
justify-content: center;
align-items: center;
position: relative;
border: none;
/* border: 1px solid #555; */
border-radius: 50%;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.14), 0 4px 8px rgba(0, 0, 0, 0.28);
cursor: pointer;

View file

@ -0,0 +1,30 @@
import * as React from 'react';
import Modal from 'react-modal';
import cx from 'classnames';
import modalStyle from '../Modal.module.css';
import s from './BaseModal.module.css';
const { useMemo } = React;
export default function BaseModal({ isOpen, onRequestClose, children }) {
const className = useMemo(
() => ({
base: cx(modalStyle.content, s.cnt),
afterOpen: s.afterOpen,
beforeClose: '',
}),
[]
);
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
className={className}
overlayClassName={cx(modalStyle.overlay, s.overlay)}
>
{children}
</Modal>
);
}

View file

@ -0,0 +1,17 @@
.overlay {
background-color: rgba(0, 0, 0, 0.6);
}
.cnt {
position: absolute;
background-color: var(--bg-modal);
color: var(--color-text);
line-height: 1.4;
opacity: 0.6;
transition: all 0.3s ease;
transform: translate(-50%, -50%) scale(1.2);
box-shadow: rgba(0, 0, 0, 0.12) 0px 4px 4px, rgba(0, 0, 0, 0.24) 0px 16px 32px;
}
.afterOpen {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}

View file

@ -0,0 +1,29 @@
.select {
height: 30px;
width: 100%;
padding-left: 8px;
background-color: transparent;
appearance: none;
/* background-color: rgb(36, 36, 36); */
/* -webkit-appearance: none; */
color: var(--color-text);
/* color: rgb(153, 153, 153); */
padding-right: 20px;
background-image: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20);
border-radius: 4px;
border-width: 1px;
border-style: solid;
border-image: initial;
border-color: var(--select-border-color);
transition: all 100ms ease 0s;
background-position: calc(100% - 8px) center;
background-repeat: no-repeat;
}
.select:hover,
.select:focus {
border-color: rgb(52, 52, 52);
outline: none !important;
color: var(--color-text-highlight);
background-image: var(--select-bg-hover);
}

View file

@ -0,0 +1,21 @@
import * as React from 'react';
import s from './Select.module.css';
type Props = {
options: Array<string[]>;
selected: string;
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => any;
};
export default function Select({ options, selected, onChange }: Props) {
return (
<select className={s.select} value={selected} onChange={onChange}>
{options.map(([value, name]) => (
<option key={value} value={value}>
{name}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,30 @@
import * as React from 'react';
type Props = {
size?: number;
color?: string;
};
export default function Equalizer({
color = 'currentColor',
size = 24,
}: Props) {
return (
<svg
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 6h9M18.5 6H22" />
<circle cx="16" cy="6" r="2" />
<path d="M22 18h-9M6 18H2" />
<circle r="2" transform="matrix(-1 0 0 1 8 18)" />
</svg>
);
}

5
src/custom.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// for css modules
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}

2
src/misc/constants.ts Normal file
View file

@ -0,0 +1,2 @@
// const ProxySortingOptions =

View file

@ -4,11 +4,13 @@ import { debounce } from '../misc/utils';
import { fetchConfigs } from './configs';
import { closeModal } from './modals';
export const getClashAPIConfig = s => s.app.clashAPIConfig;
export const getTheme = s => s.app.theme;
export const getSelectedChartStyleIndex = s => s.app.selectedChartStyleIndex;
export const getLatencyTestUrl = s => s.app.latencyTestUrl;
export const getCollapsibleIsOpen = s => s.app.collapsibleIsOpen;
export const getClashAPIConfig = (s) => s.app.clashAPIConfig;
export const getTheme = (s) => s.app.theme;
export const getSelectedChartStyleIndex = (s) => s.app.selectedChartStyleIndex;
export const getLatencyTestUrl = (s) => s.app.latencyTestUrl;
export const getCollapsibleIsOpen = (s) => s.app.collapsibleIsOpen;
export const getProxySortBy = (s) => s.app.proxySortBy;
export const getHideUnavailableProxies = (s) => s.app.hideUnavailableProxies;
const saveStateDebounced = debounce(saveState, 600);
@ -16,7 +18,7 @@ export function updateClashAPIConfig({ hostname: iHostname, port, secret }) {
return async (dispatch, getState) => {
const hostname = iHostname.trim().replace(/^http(s):\/\//, '');
const clashAPIConfig = { hostname, port, secret };
dispatch('appUpdateClashAPIConfig', s => {
dispatch('appUpdateClashAPIConfig', (s) => {
s.app.clashAPIConfig = clashAPIConfig;
});
// side effect
@ -43,7 +45,7 @@ export function switchTheme() {
const theme = currentTheme === 'light' ? 'dark' : 'light';
// side effect
setTheme(theme);
dispatch('storeSwitchTheme', s => {
dispatch('storeSwitchTheme', (s) => {
s.app.theme = theme;
});
// side effect
@ -62,7 +64,7 @@ export function clearStorage() {
export function selectChartStyleIndex(selectedChartStyleIndex) {
return (dispatch, getState) => {
dispatch('appSelectChartStyleIndex', s => {
dispatch('appSelectChartStyleIndex', (s) => {
s.app.selectedChartStyleIndex = selectedChartStyleIndex;
});
// side effect
@ -72,7 +74,7 @@ export function selectChartStyleIndex(selectedChartStyleIndex) {
export function updateAppConfig(name, value) {
return (dispatch, getState) => {
dispatch('appUpdateAppConfig', s => {
dispatch('appUpdateAppConfig', (s) => {
s.app[name] = value;
});
// side effect
@ -82,7 +84,7 @@ export function updateAppConfig(name, value) {
export function updateCollapsibleIsOpen(prefix, name, v) {
return (dispatch, getState) => {
dispatch('updateCollapsibleIsOpen', s => {
dispatch('updateCollapsibleIsOpen', (s) => {
s.app.collapsibleIsOpen[`${prefix}:${name}`] = v;
});
// side effect
@ -95,14 +97,17 @@ const defaultState = {
clashAPIConfig: {
hostname: '127.0.0.1',
port: '7892',
secret: ''
secret: '',
},
latencyTestUrl: 'http://www.gstatic.com/generate_204',
selectedChartStyleIndex: 0,
theme: 'dark',
// type { [string]: boolean }
collapsibleIsOpen: {}
collapsibleIsOpen: {},
// how proxies are sorted in a group or provider
proxySortBy: 'Natural',
hideUnavailableProxies: false,
};
function parseConfigQueryString() {

View file

@ -2,12 +2,9 @@ import {
initialState as app,
selectChartStyleIndex,
updateAppConfig,
updateCollapsibleIsOpen
updateCollapsibleIsOpen,
} from './app';
import {
initialState as proxies,
toggleUnavailableProxiesFilter
} from './proxies';
import { initialState as proxies } from './proxies';
import { initialState as modals } from './modals';
import { initialState as configs } from './configs';
import { initialState as rules } from './rules';
@ -19,16 +16,15 @@ export const initialState = {
configs,
proxies,
rules,
logs
logs,
};
export const actions = {
selectChartStyleIndex,
updateAppConfig,
// proxies
toggleUnavailableProxiesFilter,
app: {
updateCollapsibleIsOpen
}
updateCollapsibleIsOpen,
updateAppConfig,
},
};

View file

@ -11,8 +11,8 @@ type ProxyProvider = {
history: Array<{ time: string, delay: number }>,
name: string,
// Shadowsocks, Http ...
type: string
}>
type: string,
}>,
};
// see all types:
@ -30,21 +30,20 @@ const NonProxyTypes = [
'Selector',
'URLTest',
'LoadBalance',
'Unknown'
'Unknown',
];
export const getProxies = s => s.proxies.proxies;
export const getDelay = s => s.proxies.delay;
export const getRtFilterSwitch = s => s.proxies.filterZeroRT;
export const getProxyGroupNames = s => s.proxies.groupNames;
export const getProxyProviders = s => s.proxies.proxyProviders || [];
export const getDangleProxyNames = s => s.proxies.dangleProxyNames;
export const getProxies = (s) => s.proxies.proxies;
export const getDelay = (s) => s.proxies.delay;
export const getProxyGroupNames = (s) => s.proxies.groupNames;
export const getProxyProviders = (s) => s.proxies.proxyProviders || [];
export const getDangleProxyNames = (s) => s.proxies.dangleProxyNames;
export function fetchProxies(apiConfig) {
return async (dispatch, getState) => {
const [proxiesData, providersData] = await Promise.all([
proxiesAPI.fetchProxies(apiConfig),
proxiesAPI.fetchProviderProxies(apiConfig)
proxiesAPI.fetchProviderProxies(apiConfig),
]);
const [proxyProviders, providerProxies] = formatProxyProviders(
@ -77,7 +76,7 @@ export function fetchProxies(apiConfig) {
if (!providerProxies[v]) dangleProxyNames.push(v);
}
dispatch('store/proxies#fetchProxies', s => {
dispatch('store/proxies#fetchProxies', (s) => {
s.proxies.proxies = proxies;
s.proxies.groupNames = groupNames;
s.proxies.delay = delayNext;
@ -88,7 +87,7 @@ export function fetchProxies(apiConfig) {
}
export function updateProviderByName(apiConfig, name) {
return async dispatch => {
return async (dispatch) => {
try {
await proxiesAPI.updateProviderByName(apiConfig, name);
} catch (x) {
@ -109,7 +108,7 @@ async function healthcheckProviderByNameInternal(apiConfig, name) {
}
export function healthcheckProviderByName(apiConfig, name) {
return async dispatch => {
return async (dispatch) => {
await healthcheckProviderByNameInternal(apiConfig, name);
// should be optimized
// but ¯\_(ツ)_/¯
@ -118,17 +117,17 @@ export function healthcheckProviderByName(apiConfig, name) {
}
export function switchProxy(apiConfig, name1, name2) {
return async dispatch => {
return async (dispatch) => {
proxiesAPI
.requestToSwitchProxy(apiConfig, name1, name2)
.then(
res => {
(res) => {
if (res.ok === false) {
// eslint-disable-next-line no-console
console.log('failed to swith proxy', res.statusText);
}
},
err => {
(err) => {
// eslint-disable-next-line no-console
console.log(err, 'failed to swith proxy');
}
@ -137,7 +136,7 @@ export function switchProxy(apiConfig, name1, name2) {
dispatch(fetchProxies(apiConfig));
});
// optimistic UI update
dispatch('store/proxies#switchProxy', s => {
dispatch('store/proxies#switchProxy', (s) => {
const proxies = s.proxies.proxies;
if (proxies[name1] && proxies[name1].now) {
proxies[name1].now = name2;
@ -165,18 +164,18 @@ function requestDelayForProxyOnce(apiConfig, name) {
...delayPrev,
[name]: {
error,
number: delay
}
number: delay,
},
};
dispatch('requestDelayForProxyOnce', s => {
dispatch('requestDelayForProxyOnce', (s) => {
s.proxies.delay = delayNext;
});
};
}
export function requestDelayForProxy(apiConfig, name) {
return async dispatch => {
return async (dispatch) => {
await dispatch(requestDelayForProxyOnce(apiConfig, name));
};
}
@ -185,7 +184,7 @@ export function requestDelayAll(apiConfig) {
return async (dispatch, getState) => {
const proxyNames = getDangleProxyNames(getState());
await Promise.all(
proxyNames.map(p => dispatch(requestDelayForProxy(apiConfig, p)))
proxyNames.map((p) => dispatch(requestDelayForProxy(apiConfig, p)))
);
const proxyProviders = getProxyProviders(getState());
// one by one
@ -196,15 +195,6 @@ export function requestDelayAll(apiConfig) {
};
}
export function toggleUnavailableProxiesFilter() {
return (dispatch, getState) => {
const preState = getRtFilterSwitch(getState());
dispatch('store/proxies#toggleUnavailableProxiesFilter', s => {
s.proxies.filterZeroRT = !preState;
});
};
}
function retrieveGroupNamesFrom(proxies) {
let groupNames = [];
let globalAll;
@ -225,9 +215,9 @@ function retrieveGroupNamesFrom(proxies) {
globalAll.push('GLOBAL');
// Sort groups according to its index in GLOBAL group
groupNames = groupNames
.map(name => [globalAll.indexOf(name), name])
.map((name) => [globalAll.indexOf(name), name])
.sort((a, b) => a[0] - b[0])
.map(group => group[1]);
.map((group) => group[1]);
}
return [groupNames, proxyNames];
}
@ -260,5 +250,4 @@ export const initialState = {
proxies: {},
delay: {},
groupNames: [],
filterZeroRT: false
};

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": false,
"target": "es5",
"downlevelIteration": true,
"importHelpers": true,
"sourceMap": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"allowJs": false,
"checkJs": false,
"lib": ["dom", "es2015", "es2016", "es2017", "esnext"],
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"noImplicitAny": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"strictNullChecks": false,
"suppressImplicitAnyIndexErrors": true,
"types": ["jest"]
}
}

View file

@ -1,5 +1,3 @@
'use strict';
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
@ -7,9 +5,10 @@ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const HTMLPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const ForkTsCheckerNotifierWebpackPlugin = require('fork-ts-checker-notifier-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ReactRefreshWebpackPlugin = require('@hsjs/react-refresh-webpack-plugin');
// const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const pkg = require('./package.json');
@ -64,6 +63,11 @@ const bundleAnalyzerPlugin = new BundleAnalyzerPlugin({
const plugins = [
html,
definePlugin,
new ForkTsCheckerWebpackPlugin({ eslint: false }),
new ForkTsCheckerNotifierWebpackPlugin({
title: 'TypeScript',
excludeWarnings: false,
}),
new CopyPlugin([{ from: 'assets/*', flatten: true }]),
new CleanWebpackPlugin(),
// chart.js requires moment
@ -83,14 +87,26 @@ module.exports = {
// app: ['react-hot-loader/patch', './src/app.js']
app: ['./src/app.js'],
},
context: __dirname,
output: {
path: path.resolve(__dirname, 'public'),
filename: isDev ? '[name].js' : '[name].[contenthash].js',
publicPath: '',
},
mode: isDev ? 'development' : 'production',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
// disable type checker - we will use it in fork plugin
transpileOnly: true,
},
},
{
test: /\.js$/,
exclude: /node_modules/,

748
yarn.lock

File diff suppressed because it is too large Load diff