diff options
| author | Haishan <[email protected]> | 2020-09-13 16:34:18 +0800 |
|---|---|---|
| committer | Haishan <[email protected]> | 2020-09-13 17:16:14 +0800 |
| commit | 15bc0f69a8367a57fa1bf263e615285349ad4ab9 (patch) | |
| tree | fbdd2a46303703822f7e7bc3462a70b4855fe4a1 /src/components | |
| parent | a8f0d3d4b4928caebf61c75fa9191a170b471035 (diff) | |
feat: multi backends management
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/APIConfig.module.css | 2 | ||||
| -rw-r--r-- | src/components/APIConfig.tsx | 24 | ||||
| -rw-r--r-- | src/components/APIDiscovery.module.css | 2 | ||||
| -rw-r--r-- | src/components/BackendList.module.css | 99 | ||||
| -rw-r--r-- | src/components/BackendList.tsx | 147 | ||||
| -rw-r--r-- | src/components/Config.js | 8 | ||||
| -rw-r--r-- | src/components/ErrorBoundary.js | 40 | ||||
| -rw-r--r-- | src/components/Root.js | 58 | ||||
| -rw-r--r-- | src/components/TrafficChart.js | 3 |
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, |
