summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorHaishan <[email protected]>2020-09-13 16:34:18 +0800
committerHaishan <[email protected]>2020-09-13 17:16:14 +0800
commit15bc0f69a8367a57fa1bf263e615285349ad4ab9 (patch)
treefbdd2a46303703822f7e7bc3462a70b4855fe4a1 /src/components
parenta8f0d3d4b4928caebf61c75fa9191a170b471035 (diff)
feat: multi backends management
Diffstat (limited to 'src/components')
-rw-r--r--src/components/APIConfig.module.css2
-rw-r--r--src/components/APIConfig.tsx24
-rw-r--r--src/components/APIDiscovery.module.css2
-rw-r--r--src/components/BackendList.module.css99
-rw-r--r--src/components/BackendList.tsx147
-rw-r--r--src/components/Config.js8
-rw-r--r--src/components/ErrorBoundary.js40
-rw-r--r--src/components/Root.js58
-rw-r--r--src/components/TrafficChart.js3
9 files changed, 313 insertions, 70 deletions
diff --git a/src/components/APIConfig.module.css b/src/components/APIConfig.module.css
index 1092aed..afc2d53 100644
--- a/src/components/APIConfig.module.css
+++ b/src/components/APIConfig.module.css
@@ -20,7 +20,7 @@
}
.body {
- padding: 30px 0 0;
+ padding: 15px 0 0;
}
.hostnamePort {
diff --git a/src/components/APIConfig.tsx b/src/components/APIConfig.tsx
index 88bec8b..3befa5c 100644
--- a/src/components/APIConfig.tsx
+++ b/src/components/APIConfig.tsx
@@ -1,8 +1,9 @@
import * as React from 'react';
import { fetchConfigs } from 'src/api/configs';
+import { BackendList } from 'src/components/BackendList';
import { ClashAPIConfig } from 'src/types';
-import { getClashAPIConfig, updateClashAPIConfig } from '../store/app';
+import { addClashAPIConfig, getClashAPIConfig } from '../store/app';
import s0 from './APIConfig.module.css';
import Button from './Button';
import Field from './Field';
@@ -16,9 +17,9 @@ const mapState = (s) => ({
apiConfig: getClashAPIConfig(s),
});
-function APIConfig({ apiConfig, dispatch }) {
- const [baseURL, setBaseURL] = useState(apiConfig.baseURL);
- const [secret, setSecret] = useState(apiConfig.secret);
+function APIConfig({ dispatch }) {
+ const [baseURL, setBaseURL] = useState('');
+ const [secret, setSecret] = useState('');
const [errMsg, setErrMsg] = useState('');
const userTouchedFlagRef = useRef(false);
@@ -47,14 +48,21 @@ function APIConfig({ apiConfig, dispatch }) {
if (ret[0] !== Ok) {
setErrMsg(ret[1]);
} else {
- dispatch(updateClashAPIConfig({ baseURL, secret }));
+ dispatch(addClashAPIConfig({ baseURL, secret }));
}
});
}, [baseURL, secret, dispatch]);
const handleContentOnKeyDown = useCallback(
- (e) => {
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (
+ e.target instanceof Element &&
+ (!e.target.tagName || e.target.tagName.toUpperCase() !== 'INPUT')
+ ) {
+ return;
+ }
if (e.key !== 'Enter') return;
+
onConfirm();
},
[onConfirm]
@@ -90,8 +98,10 @@ function APIConfig({ apiConfig, dispatch }) {
</div>
<div className={s0.error}>{errMsg ? errMsg : null}</div>
<div className={s0.footer}>
- <Button label="Confirm" onClick={onConfirm} />
+ <Button label="Add" onClick={onConfirm} />
</div>
+ <div style={{ height: 20 }} />
+ <BackendList />
</div>
);
}
diff --git a/src/components/APIDiscovery.module.css b/src/components/APIDiscovery.module.css
index f2aaf71..6c1295a 100644
--- a/src/components/APIDiscovery.module.css
+++ b/src/components/APIDiscovery.module.css
@@ -12,11 +12,11 @@
display: flex;
justify-content: center;
+ overflow-y: auto;
}
.container {
position: relative;
- top: 10%;
margin-left: 20px;
margin-right: 20px;
}
diff --git a/src/components/BackendList.module.css b/src/components/BackendList.module.css
new file mode 100644
index 0000000..1de1972
--- /dev/null
+++ b/src/components/BackendList.module.css
@@ -0,0 +1,99 @@
+.ul {
+ position: relative;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ line-height: 1.8;
+
+ --width-max-content: 230px;
+}
+
+.li {
+ position: relative;
+ margin: 5px 0;
+ padding: 10px 0;
+ border-radius: 10px;
+ display: grid;
+ place-content: center;
+ grid-template-columns: 40px 1fr 40px;
+ grid-template-rows: 30px;
+ grid-template-areas: 'close url .';
+ column-gap: 10px;
+}
+
+.li:hover {
+ background-color: var(--bg-near-transparent);
+}
+
+.close {
+ opacity: 0;
+ grid-area: close;
+ place-self: center;
+}
+
+.li:hover .close,
+.li:hover .eye {
+ opacity: 1;
+}
+.close:focus,
+.eye:focus {
+ opacity: 1;
+}
+
+.hasSecret {
+ grid-template-rows: repeat(2, 30px);
+ grid-template-areas:
+ 'close url .'
+ 'close secret eye';
+}
+
+.url {
+ grid-area: url;
+}
+.secret {
+ grid-area: secret;
+}
+.eye {
+ grid-area: eye;
+ opacity: 0;
+ place-self: center;
+ cursor: pointer;
+}
+
+.url,
+.secret {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.btn {
+ outline: none;
+ appearance: none;
+ border: 1px solid transparent;
+ background-color: transparent;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ padding: 5px;
+ border-radius: 100px;
+}
+.btn:focus {
+ border-color: var(--color-focus-blue);
+}
+.btn:hover:enabled {
+ background-color: var(--color-focus-blue);
+}
+.btn:active:enabled {
+ transform: scale(0.97);
+}
+.btn:disabled {
+ color: var(--color-text-secondary);
+}
+
+.url {
+ cursor: pointer;
+}
+.url:hover {
+ color: var(--color-text-highlight);
+}
diff --git a/src/components/BackendList.tsx b/src/components/BackendList.tsx
new file mode 100644
index 0000000..a0c993f
--- /dev/null
+++ b/src/components/BackendList.tsx
@@ -0,0 +1,147 @@
+import cx from 'clsx';
+import * as React from 'react';
+import { Eye, EyeOff, X as Close } from 'react-feather';
+import { useToggle } from 'src/hooks/basic';
+import {
+ getClashAPIConfigs,
+ getSelectedClashAPIConfigIndex,
+} from 'src/store/app';
+import { ClashAPIConfig } from 'src/types';
+
+import s from './BackendList.module.css';
+import { connect, useStoreActions } from './StateProvider';
+
+type Config = ClashAPIConfig & { addedAt: number };
+
+const mapState = (s) => ({
+ apiConfigs: getClashAPIConfigs(s),
+ selectedClashAPIConfigIndex: getSelectedClashAPIConfigIndex(s),
+});
+
+export const BackendList = connect(mapState)(BackendListImpl);
+
+function BackendListImpl({
+ apiConfigs,
+ selectedClashAPIConfigIndex,
+}: {
+ apiConfigs: Config[];
+ selectedClashAPIConfigIndex: number;
+}) {
+ const {
+ app: { removeClashAPIConfig, selectClashAPIConfig },
+ } = useStoreActions();
+
+ const onRemove = React.useCallback(
+ (conf: ClashAPIConfig) => {
+ removeClashAPIConfig(conf);
+ },
+ [removeClashAPIConfig]
+ );
+ const onSelect = React.useCallback(
+ (conf: ClashAPIConfig) => {
+ selectClashAPIConfig(conf);
+ },
+ [selectClashAPIConfig]
+ );
+
+ return (
+ <>
+ <ul className={s.ul}>
+ {apiConfigs.map((item, idx) => {
+ return (
+ <li
+ className={cx(s.li, {
+ [s.hasSecret]: item.secret,
+ [s.isSelected]: idx === selectedClashAPIConfigIndex,
+ })}
+ key={item.baseURL + item.secret}
+ >
+ <Item
+ disableRemove={idx === selectedClashAPIConfigIndex}
+ baseURL={item.baseURL}
+ secret={item.secret}
+ onRemove={onRemove}
+ onSelect={onSelect}
+ />
+ </li>
+ );
+ })}
+ </ul>
+ </>
+ );
+}
+
+function Item({
+ baseURL,
+ secret,
+ disableRemove,
+ onRemove,
+ onSelect,
+}: {
+ baseURL: string;
+ secret: string;
+ disableRemove: boolean;
+ onRemove: (x: ClashAPIConfig) => void;
+ onSelect: (x: ClashAPIConfig) => void;
+}) {
+ const [show, toggle] = useToggle();
+ const Icon = show ? EyeOff : Eye;
+
+ const handleTap = React.useCallback((e: React.KeyboardEvent) => {
+ e.stopPropagation();
+ }, []);
+
+ return (
+ <>
+ <Button
+ disabled={disableRemove}
+ onClick={() => onRemove({ baseURL, secret })}
+ className={s.close}
+ >
+ <Close size={20} />
+ </Button>
+ <span
+ className={s.url}
+ tabIndex={0}
+ role="button"
+ onClick={() => onSelect({ baseURL, secret })}
+ onKeyUp={handleTap}
+ >
+ {baseURL}
+ </span>
+ <span />
+ {secret ? (
+ <>
+ <span className={s.secret}>{show ? secret : '***'}</span>
+
+ <Button onClick={toggle} className={s.eye}>
+ <Icon size={20} />
+ </Button>
+ </>
+ ) : null}
+ </>
+ );
+}
+
+function Button({
+ children,
+ onClick,
+ className,
+ disabled,
+}: {
+ children: React.ReactNode;
+
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
+ className: string;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ disabled={disabled}
+ className={cx(className, s.btn)}
+ onClick={onClick}
+ >
+ {children}
+ </button>
+ );
+}
diff --git a/src/components/Config.js b/src/components/Config.js
index d5850d1..f14f7d9 100644
--- a/src/components/Config.js
+++ b/src/components/Config.js
@@ -2,12 +2,12 @@ import PropTypes from 'prop-types';
import React from 'react';
import {
- clearStorage,
getClashAPIConfig,
getLatencyTestUrl,
getSelectedChartStyleIndex,
} from '../store/app';
import { fetchConfigs, getConfigs, updateConfigs } from '../store/configs';
+import { openModal } from '../store/modals';
import Button from './Button';
import s0 from './Config.module.css';
import ContentHeader from './ContentHeader';
@@ -104,6 +104,10 @@ function ConfigImpl({
refConfigs.current = configs;
}, [configs]);
+ const openAPIConfigModal = useCallback(() => {
+ dispatch(openModal('apiConfig'));
+ }, [dispatch]);
+
const setConfigState = useCallback(
(name, val) => {
setConfigStateInternal({
@@ -256,7 +260,7 @@ function ConfigImpl({
</div>
<div>
<div className={s0.label}>Action</div>
- <Button label="Log out" onClick={clearStorage} />
+ <Button label="Switch backend" onClick={openAPIConfigModal} />
</div>
</div>
</div>
diff --git a/src/components/ErrorBoundary.js b/src/components/ErrorBoundary.js
index cc3898e..ff49e1e 100644
--- a/src/components/ErrorBoundary.js
+++ b/src/components/ErrorBoundary.js
@@ -1,13 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+// import { getSentry } from '../misc/sentry';
import { deriveMessageFromError } from '../misc/errors';
-import { getSentry } from '../misc/sentry';
import ErrorBoundaryFallback from './ErrorBoundaryFallback';
-// XXX this is no Hook equivalents for componentDidCatch
-// we have to use class for now
-
class ErrorBoundary extends Component {
static propTypes = {
children: PropTypes.node,
@@ -15,44 +12,13 @@ class ErrorBoundary extends Component {
state = { error: null };
- loadSentry = async () => {
- if (this.sentry) return this.sentry;
- const x = await getSentry();
- this.sentry = x;
- return this.sentry;
- };
-
- // static getDerivedStateFromError(error) {
- // return { error };
- // }
-
- componentDidMount() {
- // this.loadSentry();
+ static getDerivedStateFromError(error) {
+ return { error };
}
- componentDidCatch(error, _info) {
- this.setState({ error });
- // eslint-disable-next-line no-console
- // console.log(error, errorInfo);
- // this.setState({ error });
- // this.loadSentry().then(Sentry => {
- // Sentry.withScope(scope => {
- // Object.keys(errorInfo).forEach(key => {
- // scope.setExtra(key, errorInfo[key]);
- // });
- // Sentry.captureException(error);
- // });
- // });
- }
-
- showReportDialog = () => {
- this.loadSentry().then((Sentry) => Sentry.showReportDialog());
- };
-
render() {
if (this.state.error) {
const { message, detail } = deriveMessageFromError(this.state.error);
- //render fallback UI
return <ErrorBoundaryFallback message={message} detail={detail} />;
} else {
return this.props.children;
diff --git a/src/components/Root.js b/src/components/Root.js
index 6c13163..8c06100 100644
--- a/src/components/Root.js
+++ b/src/components/Root.js
@@ -1,11 +1,12 @@
import './Root.css';
import React, { lazy, Suspense } from 'react';
-import { HashRouter as Router, Route, Routes } from 'react-router-dom';
+import { HashRouter as Router, useRoutes } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { About } from 'src/components/about/About';
import { actions, initialState } from '../store';
+import APIConfig from './APIConfig';
import APIDiscovery from './APIDiscovery';
import ErrorBoundary from './ErrorBoundary';
import Home from './Home';
@@ -52,33 +53,50 @@ const Rules = lazy(() =>
);
const routes = [
- ['home', '/', <Home />],
- ['connections', '/connections', <Connections />],
- ['configs', '/configs', <Config />],
- ['logs', '/logs', <Logs />],
- ['proxies', '/proxies', <Proxies />],
- ['rules', '/rules', <Rules />],
- ['about', '/about', <About />],
- __DEV__ ? ['style', '/style', <StyleGuide />] : false,
+ { path: '/', element: <Home /> },
+ { path: '/connections', element: <Connections /> },
+ { path: '/configs', element: <Config /> },
+ { path: '/logs', element: <Logs /> },
+ { path: '/proxies', element: <Proxies /> },
+ { path: '/rules', element: <Rules /> },
+ { path: '/about', element: <About /> },
+ __DEV__ ? { path: '/style', element: <StyleGuide /> } : false,
].filter(Boolean);
+function RouteInnerApp() {
+ return useRoutes(routes);
+}
+
+function SideBarApp() {
+ return (
+ <>
+ <APIDiscovery />
+ <SideBar />
+ <div className={s0.content}>
+ <Suspense fallback={<Loading2 />}>
+ <RouteInnerApp />
+ </Suspense>
+ </div>
+ </>
+ );
+}
+
+function App() {
+ return useRoutes([
+ { path: '/backend', element: <APIConfig /> },
+ { path: '*', element: <SideBarApp /> },
+ ]);
+}
+
const Root = () => (
<ErrorBoundary>
<RecoilRoot>
<StateProvider initialState={initialState} actions={actions}>
<Router>
<div className={s0.app}>
- <APIDiscovery />
- <SideBar />
- <div className={s0.content}>
- <Suspense fallback={<Loading2 />}>
- <Routes>
- {routes.map(([key, path, element]) => (
- <Route key={key} path={path} element={element} />
- ))}
- </Routes>
- </Suspense>
- </div>
+ <Suspense fallback={<Loading2 />}>
+ <App />
+ </Suspense>
</div>
</Router>
</StateProvider>
diff --git a/src/components/TrafficChart.js b/src/components/TrafficChart.js
index 18bda77..bcfd4dc 100644
--- a/src/components/TrafficChart.js
+++ b/src/components/TrafficChart.js
@@ -25,8 +25,7 @@ export default connect(mapState)(TrafficChart);
function TrafficChart({ apiConfig, selectedChartStyleIndex }) {
const Chart = chartJSResource.read();
- const { hostname, port, secret } = apiConfig;
- const traffic = fetchData({ hostname, port, secret });
+ const traffic = fetchData(apiConfig);
const data = useMemo(
() => ({
labels: traffic.labels,