diff options
| author | Haishan <[email protected]> | 2019-12-20 17:45:05 +0800 |
|---|---|---|
| committer | Haishan <[email protected]> | 2019-12-20 17:45:05 +0800 |
| commit | d81592ec970d207d4e37beb6c275ad6b77979e39 (patch) | |
| tree | 33aac796297864d95307f21d6a9aa790e3c33c09 /src | |
| parent | 040c5de04a75415490f9c478d931b7707bfa2486 (diff) | |
feat: support proxy provider
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/proxies.js | 24 | ||||
| -rw-r--r-- | src/components/Button.js | 9 | ||||
| -rw-r--r-- | src/components/Button.module.css | 16 | ||||
| -rw-r--r-- | src/components/Proxies.js | 70 | ||||
| -rw-r--r-- | src/components/Proxies.module.css | 4 | ||||
| -rw-r--r-- | src/components/Proxy.js | 77 | ||||
| -rw-r--r-- | src/components/Proxy.module.css | 14 | ||||
| -rw-r--r-- | src/components/ProxyGroup.js | 130 | ||||
| -rw-r--r-- | src/components/ProxyGroup.module.css | 25 | ||||
| -rw-r--r-- | src/components/ProxyLatency.js | 35 | ||||
| -rw-r--r-- | src/components/ProxyProvider.js | 211 | ||||
| -rw-r--r-- | src/components/ProxyProvider.module.css | 43 | ||||
| -rw-r--r-- | src/components/ProxyProviderList.js | 19 | ||||
| -rw-r--r-- | src/components/Root.css | 13 | ||||
| -rw-r--r-- | src/components/Root.js | 51 | ||||
| -rw-r--r-- | src/components/StateProvider.js | 79 | ||||
| -rw-r--r-- | src/components/shared/Basic.js | 12 | ||||
| -rw-r--r-- | src/components/shared/Basic.module.css | 14 | ||||
| -rw-r--r-- | src/ducks/index.js | 2 | ||||
| -rw-r--r-- | src/store/proxies.js (renamed from src/ducks/proxies.js) | 174 |
20 files changed, 776 insertions, 246 deletions
diff --git a/src/api/proxies.js b/src/api/proxies.js index caa6da4..3ffb275 100644 --- a/src/api/proxies.js +++ b/src/api/proxies.js @@ -18,13 +18,13 @@ Vary: Origin Date: Tue, 16 Oct 2018 16:38:33 GMT */ -async function fetchProxies(config) { +export async function fetchProxies(config) { const { url, init } = getURLAndInit(config); const res = await fetch(url + endpoint, init); return await res.json(); } -async function requestToSwitchProxy(apiConfig, name1, name2) { +export async function requestToSwitchProxy(apiConfig, name1, name2) { const body = { name: name2 }; const { url, init } = getURLAndInit(apiConfig); const fullURL = `${url}${endpoint}/${name1}`; @@ -35,11 +35,27 @@ async function requestToSwitchProxy(apiConfig, name1, name2) { }); } -async function requestDelayForProxy(apiConfig, name) { +export async function requestDelayForProxy(apiConfig, name) { const { url, init } = getURLAndInit(apiConfig); const qs = 'timeout=5000&url=http://www.google.com/generate_204'; const fullURL = `${url}${endpoint}/${name}/delay?${qs}`; return await fetch(fullURL, init); } -export { fetchProxies, requestToSwitchProxy, requestDelayForProxy }; +export async function fetchProviderProxies(config) { + const { url, init } = getURLAndInit(config); + const res = await fetch(url + '/providers/proxies', init); + if (res.status === 404) { + return { providers: {} }; + } + return await res.json(); +} + +export async function updateProviderByName(config, name) { + const { url, init } = getURLAndInit(config); + const options = { + ...init, + method: 'PUT' + }; + return await fetch(url + '/providers/proxies/' + name, options); +} diff --git a/src/components/Button.js b/src/components/Button.js index f56049e..5b0365b 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,4 +1,5 @@ import React from 'react'; +import cx from 'classnames'; import s0 from 'c/Button.module.css'; const noop = () => {}; @@ -13,6 +14,14 @@ function Button({ children, label, onClick = noop }, ref) { ); } +export function ButtonPlain({ children, label, onClick = noop }) { + return ( + <button className={cx(s0.btn, s0.plain)} onClick={onClick}> + {children || label} + </button> + ); +} + function WithIcon({ text, icon, onClick = noop }, ref) { return ( <button className={s0.btn} ref={ref} onClick={onClick}> diff --git a/src/components/Button.module.css b/src/components/Button.module.css index 205bfe9..c232a66 100644 --- a/src/components/Button.module.css +++ b/src/components/Button.module.css @@ -24,6 +24,22 @@ font-size: 1em; padding: 6px 12px; } + + &.plain { + border-radius: 100%; + padding: 0; + display: flex; + border-color: transparent; + background: none; + &:focus { + border-color: var(--color-focus-blue); + } + &:hover { + background: #387cec; + border: 1px solid #387cec; + color: #fff; + } + } } .withIconWrapper { diff --git a/src/components/Proxies.js b/src/components/Proxies.js index fef6a14..0020114 100644 --- a/src/components/Proxies.js +++ b/src/components/Proxies.js @@ -1,63 +1,81 @@ import React from 'react'; -import { useActions, useStoreState } from 'm/store'; +import { useStoreState } from 'm/store'; -import ContentHeader from 'c/ContentHeader'; -import ProxyGroup from 'c/ProxyGroup'; -import { ButtonWithIcon } from 'c/Button'; +import { connect } from './StateProvider'; + +import ContentHeader from './ContentHeader'; +import ProxyGroup from './ProxyGroup'; +import { ButtonWithIcon } from './Button'; import { Zap } from 'react-feather'; -import s0 from 'c/Proxies.module.css'; +import ProxyProviderList from './ProxyProviderList'; + +import s0 from './Proxies.module.css'; import { getProxies, + getDelay, getProxyGroupNames, + getProxyProviders, fetchProxies, requestDelayAll -} from 'd/proxies'; +} from '../store/proxies'; + +import { getClashAPIConfig } from '../ducks/app'; -const { useEffect, useMemo } = React; +const { useEffect, useMemo, useCallback } = React; const mapStateToProps = s => ({ - proxies: getProxies(s), - groupNames: getProxyGroupNames(s) + apiConfig: getClashAPIConfig(s) }); -const actions = { - fetchProxies, - requestDelayAll -}; - -export default function Proxies() { - const { fetchProxies, requestDelayAll } = useActions(actions); +function Proxies({ dispatch, groupNames, proxies, delay, proxyProviders }) { + const { apiConfig } = useStoreState(mapStateToProps); useEffect(() => { - (async () => { - await fetchProxies(); - // await requestDelayAll(); - })(); - }, [fetchProxies, requestDelayAll]); - const { groupNames } = useStoreState(mapStateToProps); + dispatch(fetchProxies(apiConfig)); + }, [dispatch, apiConfig]); + const requestDelayAllFn = useCallback( + () => dispatch(requestDelayAll(apiConfig)), + [apiConfig, dispatch] + ); const icon = useMemo(() => <Zap width={16} />, []); return ( <> <ContentHeader title="Proxies" /> - <div className={s0.body}> + <div> <div className="fabgrp"> <ButtonWithIcon text="Test Latency" icon={icon} - onClick={requestDelayAll} + onClick={requestDelayAllFn} /> - {/* <Button onClick={requestDelayAll}>Test Latency</Button> */} </div> {groupNames.map(groupName => { return ( <div className={s0.group} key={groupName}> - <ProxyGroup name={groupName} /> + <ProxyGroup + name={groupName} + proxies={proxies} + delay={delay} + apiConfig={apiConfig} + dispatch={dispatch} + /> </div> ); })} </div> + <ProxyProviderList items={proxyProviders} /> + <div style={{ height: 60 }} /> </> ); } + +const mapState = s => ({ + groupNames: getProxyGroupNames(s), + proxies: getProxies(s), + proxyProviders: getProxyProviders(s), + delay: getDelay(s) +}); + +export default connect(mapState)(Proxies); diff --git a/src/components/Proxies.module.css b/src/components/Proxies.module.css index 72b70fb..5520a2e 100644 --- a/src/components/Proxies.module.css +++ b/src/components/Proxies.module.css @@ -1,7 +1,3 @@ -.body { - padding-bottom: 50px; -} - .group { padding: 10px 15px; @media (--breakpoint-not-small) { diff --git a/src/components/Proxy.js b/src/components/Proxy.js index b7efc84..117ff76 100644 --- a/src/components/Proxy.js +++ b/src/components/Proxy.js @@ -1,13 +1,36 @@ import React from 'react'; -import PropTypes from 'prop-types'; import cx from 'classnames'; -import { useStoreState } from 'm/store'; -import ProxyLatency from 'c/ProxyLatency'; +import { connect } from './StateProvider'; +import ProxyLatency from './ProxyLatency'; + +import { getProxies, getDelay } from '../store/proxies'; import s0 from './Proxy.module.css'; -import { getDelay, getProxies } from 'd/proxies'; +const { useMemo } = React; + +const colorMap = { + // green + good: '#67c23a', + // yellow + normal: '#d4b75c', + // orange + bad: '#e67f3c', + // bad: '#F56C6C', + na: '#909399' +}; + +function getLabelColor({ number, error } = {}) { + if (number < 200) { + return colorMap.good; + } else if (number < 400) { + return colorMap.normal; + } else if (typeof number === 'number') { + return colorMap.bad; + } + return colorMap.na; +} /* const colors = { @@ -22,18 +45,28 @@ const colors = { }; */ -const mapStateToProps = s => { - return { - proxies: getProxies(s), - delay: getDelay(s) - }; +type ProxyProps = { + name: string, + now?: boolean, + + // connect injected + // TODO refine type + proxy: any, + latency: any }; -function Proxy({ now, name }) { - const { proxies, delay } = useStoreState(mapStateToProps); - const latency = delay[name]; - const proxy = proxies[name]; +function ProxySmallImpl({ now, name, proxy, latency }: ProxyProps) { + const color = useMemo(() => getLabelColor(latency), [latency]); + return ( + <div + className={cx(s0.proxySmall, { [s0.now]: now })} + style={{ backgroundColor: color }} + /> + ); +} +function Proxy({ now, name, proxy, latency }: ProxyProps) { + const color = useMemo(() => getLabelColor(latency), [latency]); return ( <div className={cx(s0.proxy, { @@ -46,14 +79,22 @@ function Proxy({ now, name }) { {proxy.type} </div> <div className={s0.proxyLatencyWrap}> - {latency && latency.number ? <ProxyLatency latency={latency} /> : null} + {latency && latency.number ? ( + <ProxyLatency number={latency.number} color={color} /> + ) : null} </div> </div> ); } -Proxy.propTypes = { - now: PropTypes.bool, - name: PropTypes.string + +const mapState = (s, { name }) => { + const proxies = getProxies(s); + const delay = getDelay(s); + return { + proxy: proxies[name], + latency: delay[name] + }; }; -export default Proxy; +export default connect(mapState)(Proxy); +export const ProxySmall = connect(mapState)(ProxySmallImpl); diff --git a/src/components/Proxy.module.css b/src/components/Proxy.module.css index 6f42ccf..2af1ce8 100644 --- a/src/components/Proxy.module.css +++ b/src/components/Proxy.module.css @@ -2,10 +2,15 @@ position: relative; padding: 5px; border-radius: 8px; + overflow: hidden; + + max-width: 280px; @media (--breakpoint-not-small) { + min-width: 150px; border-radius: 10px; padding: 10px; } + background-color: var(--color-bg-proxy-selected); &.now { background-color: var(--color-focus-blue); @@ -40,3 +45,12 @@ display: flex; align-items: flex-end; } + +.proxySmall { + .now { + outline: pink solid 1px; + } + width: 12px; + height: 12px; + border-radius: 8px; +} diff --git a/src/components/ProxyGroup.js b/src/components/ProxyGroup.js index 337b09b..d824920 100644 --- a/src/components/ProxyGroup.js +++ b/src/components/ProxyGroup.js @@ -1,60 +1,108 @@ import React from 'react'; -import PropTypes from 'prop-types'; import cx from 'classnames'; -import { useActions, useStoreState } from 'm/store'; -import Proxy from 'c/Proxy'; +import Proxy, { ProxySmall } from './Proxy'; +import { SectionNameType } from './shared/Basic'; import s0 from './ProxyGroup.module.css'; -import { getProxies, switchProxy } from 'd/proxies'; +import { switchProxy } from '../store/proxies'; -const mapStateToProps = s => ({ - proxies: getProxies(s) -}); +const { memo, useCallback, useMemo } = React; -export default function ProxyGroup({ name }) { - const { proxies } = useStoreState(mapStateToProps); - const actions = useActions({ switchProxy }); +function ProxyGroup({ name, proxies, apiConfig, dispatch }) { const group = proxies[name]; - const { all } = group; + const { all, type, now } = group; + + const isSelectable = useMemo(() => type === 'Selector', [type]); + + const itemOnTapCallback = useCallback( + proxyName => { + if (!isSelectable) return; + + dispatch(switchProxy(apiConfig, name, proxyName)); + // switchProxyFn(name, proxyName); + }, + [apiConfig, dispatch, name, isSelectable] + ); return ( <div className={s0.group}> <div className={s0.header}> - <h2> - <span>{name}</span> - <span>{group.type}</span> - </h2> - </div> - <div className={s0.list}> - {all.map(proxyName => { - const isSelectable = group.type === 'Selector'; - const proxyClassName = cx(s0.proxy, { - [s0.proxySelectable]: isSelectable - }); - return ( - <div - className={proxyClassName} - key={proxyName} - onClick={() => { - if (!isSelectable) return; - actions.switchProxy(name, proxyName); - }} - > - <Proxy - isSelectable={isSelectable} - name={proxyName} - now={proxyName === group.now} - /> - </div> - ); - })} + <SectionNameType name={name} type={group.type} /> </div> + <ProxyList + all={all} + now={now} + isSelectable={isSelectable} + itemOnTapCallback={itemOnTapCallback} + /> </div> ); } -ProxyGroup.propTypes = { - name: PropTypes.string +type ProxyListProps = { + all: string[], + now?: string, + isSelectable?: boolean, + itemOnTapCallback?: string => void }; +export function ProxyList({ + all, + now, + isSelectable, + itemOnTapCallback +}: ProxyListProps) { + return ( + <div className={s0.list}> + {all.map(proxyName => { + const proxyClassName = cx(s0.proxy, { + [s0.proxySelectable]: isSelectable + }); + return ( + <div + className={proxyClassName} + key={proxyName} + onClick={() => { + if (!isSelectable || !itemOnTapCallback) return; + itemOnTapCallback(proxyName); + }} + > + <Proxy name={proxyName} now={proxyName === now} /> + </div> + ); + })} + </div> + ); +} + +export function ProxyListSummaryView({ + all, + now, + isSelectable, + itemOnTapCallback +}: ProxyListProps) { + return ( + <div className={s0.list}> + {all.map(proxyName => { + const proxyClassName = cx(s0.proxy, { + [s0.proxySelectable]: isSelectable + }); + return ( + <div + className={proxyClassName} + key={proxyName} + onClick={() => { + if (!isSelectable || !itemOnTapCallback) return; + itemOnTapCallback(proxyName); + }} + > + <ProxySmall name={proxyName} now={proxyName === now} /> + </div> + ); + })} + </div> + ); +} + +export default memo(ProxyGroup); diff --git a/src/components/ProxyGroup.module.css b/src/components/ProxyGroup.module.css index 08c4b42..748aa67 100644 --- a/src/components/ProxyGroup.module.css +++ b/src/components/ProxyGroup.module.css @@ -1,32 +1,19 @@ .header { - > h2 { - margin-top: 0; - - font-size: 1.3em; - @media (--breakpoint-not-small) { - font-size: 1.5em; - } - - span:nth-child(2) { - font-size: 12px; - color: #777; - font-weight: normal; - margin: 0 0.3em; - } - } + margin-bottom: 12px; } .list { display: flex; flex-wrap: wrap; + margin-top: 8px; } .proxy { - max-width: 280px; - margin: 2px; + margin-right: 5px; + margin-bottom: 5px; @media (--breakpoint-not-small) { - min-width: 150px; - margin: 10px; + margin-right: 10px; + margin-bottom: 10px; } transition: transform 0.2s ease-in-out; diff --git a/src/components/ProxyLatency.js b/src/components/ProxyLatency.js index 33a94f5..5cde880 100644 --- a/src/components/ProxyLatency.js +++ b/src/components/ProxyLatency.js @@ -1,39 +1,16 @@ -import React, { useMemo } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; import s0 from './ProxyLatency.module.css'; -const colorMap = { - good: '#67C23A', - normal: '#E6A23C', - bad: '#F56C6C', - na: '#909399' +type ProxyLatencyProps = { + number: number, + color: string }; -function getLabelColor(number, error) { - if (error !== '') { - return colorMap.na; - } else if (number < 200) { - return colorMap.good; - } else if (number < 400) { - return colorMap.normal; - } - return colorMap.bad; -} - -export default function ProxyLatency({ latency }) { - const { number, error } = latency; - const color = useMemo(() => getLabelColor(number, error), [number, error]); +export default function ProxyLatency({ number, color }: ProxyLatencyProps) { return ( <span className={s0.proxyLatency} style={{ color }}> - {error !== '' ? <span>{error}</span> : <span>{number} ms</span>} + <span>{number} ms</span> </span> ); } - -ProxyLatency.propTypes = { - latency: PropTypes.shape({ - number: PropTypes.number, - error: PropTypes.string - }) -}; diff --git a/src/components/ProxyProvider.js b/src/components/ProxyProvider.js new file mode 100644 index 0000000..e18fe17 --- /dev/null +++ b/src/components/ProxyProvider.js @@ -0,0 +1,211 @@ +import React from 'react'; +import { ChevronDown, RotateCw } from 'react-feather'; +import { formatDistance } from 'date-fns'; +import ResizeObserver from 'resize-observer-polyfill'; +import { motion } from 'framer-motion'; +import cx from 'classnames'; + +import { useStoreState } from '../misc/store'; +import { getClashAPIConfig } from '../ducks/app'; +import { connect } from './StateProvider'; +import { SectionNameType } from './shared/Basic'; +import { ProxyList, ProxyListSummaryView } from './ProxyGroup'; +import { ButtonWithIcon, ButtonPlain } from './Button'; + +import { updateProviderByName } from '../store/proxies'; + +import s from './ProxyProvider.module.css'; + +const { memo, useState, useRef, useEffect, useCallback } = React; + +type Props = { + item: Array<{ + name: string, + proxies: Array<string>, + type: 'Proxy' | 'Rule', + vehicleType: 'HTTP' | 'File' | 'Compatible', + updatedAt?: string + }>, + proxies: { + [string]: any + }, + dispatch: any => void +}; + +const mapStateToProps = s => ({ + apiConfig: getClashAPIConfig(s) +}); + +function ProxyProvider({ item, dispatch }: Props) { + const { apiConfig } = useStoreState(mapStateToProps); + const updateProvider = useCallback( + () => dispatch(updateProviderByName(apiConfig, item.name)), + [apiConfig, dispatch, item.name] + ); + + const [isCollapsibleOpen, setCollapsibleOpen] = useState(false); + const toggle = useCallback(() => setCollapsibleOpen(x => !x), []); + const timeAgo = formatDistance(new Date(item.updatedAt), new Date()); + return ( + <div className={s.body}> + <div className={s.header} onClick={toggle}> + <SectionNameType name={item.name} type={item.vehicleType} /> + <ButtonPlain> + <span className={cx(s.arrow, { [s.isOpen]: isCollapsibleOpen })}> + <ChevronDown /> + </span> + </ButtonPlain> + </div> + <div className={s.updatedAt}> + <small>Updated {timeAgo} ago</small> + </div> + <Collapsible2 isOpen={isCollapsibleOpen}> + <ProxyList all={item.proxies} /> + <div className={s.actionFooter}> + <ButtonWithIcon + text="Update" + icon={<Refresh />} + onClick={updateProvider} + /> + </div> + </Collapsible2> + <Collapsible2 isOpen={!isCollapsibleOpen}> + <ProxyListSummaryView all={item.proxies} /> + </Collapsible2> + </div> + ); +} + +const button = { + rest: { scale: 1 }, + // hover: { scale: 1.1 }, + pressed: { scale: 0.95 } +}; +const arrow = { + rest: { rotate: 0 }, + hover: { rotate: 360, transition: { duration: 0.3 } } +}; +function Refresh() { + return ( + <motion.div + className={s.refresh} + variants={button} + initial="rest" + whileHover="hover" + whileTap="pressed" + > + <motion.div className="flexCenter" variants={arrow}> + <RotateCw size={16} /> + </motion.div> + </motion.div> + ); +} + +function usePrevious(value) { + const ref = useRef(); + useEffect(() => void (ref.current = value), [value]); + return ref.current; +} + +function useMeasure() { + const ref = useRef(); + const [bounds, set] = useState({ height: 0 }); + useEffect(() => { + const ro = new ResizeObserver(([entry]) => set(entry.contentRect)); + if (ref.current) ro.observe(ref.current); + return () => ro.disconnect(); + }, []); + return [ref, bounds]; +} + +// import { useSpring, a } from 'react-spring'; +// const Collapsible = memo(({ children, isOpen }) => { +// const previous = usePrevious(isOpen); +// const [refToMeature, { height: viewHeight }] = useMeasure(); +// const { height, opacity, visibility, transform } = useSpring({ +// from: { +// height: 0, +// opacity: 0, +// transform: 'translate3d(20px,0,0)', +// visibility: 'hidden' +// }, +// to: { +// height: isOpen ? viewHeight : 0, +// opacity: isOpen ? 1 : 0, +// visibility: isOpen ? 'visible' : 'hidden', +// transform: `translate3d(${isOpen ? 0 : 20}px,0,0)` +// } +// }); +// return ( +// <div> +// <a.div +// style={{ +// opacity, +// willChange: 'transform, opacity, height, visibility', +// visibility, +// height: isOpen && previous === isOpen ? 'auto' : height +// }}> +// <a.div style={{ transform }} ref={refToMeature} children={children} /> +// </a.div> +// </div> +// ); +// }); + +const variantsCollpapsibleWrap = { + initialOpen: { + height: 'auto', + transition: { duration: 0 } + }, + open: height => ({ + height, + opacity: 1, + visibility: 'visible', + transition: { duration: 0.3 } + }), + closed: { + height: 0, + opacity: 0, + visibility: 'hidden', + transition: { duration: 0.3 } + } +}; +const variantsCollpapsibleChildContainer = { + open: { + x: 0 + }, + closed: { + x: 20 + } +}; + +const Collapsible2 = memo(({ children, isOpen }) => { + const previous = usePrevious(isOpen); + const [refToMeature, { height }] = useMeasure(); + return ( + <div> + <motion.div + animate={ + isOpen && previous === isOpen + ? 'initialOpen' + : isOpen + ? 'open' + : 'closed' + } + custom={height} + variants={variantsCollpapsibleWrap} + > + <motion.div + variants={variantsCollpapsibleChildContainer} + ref={refToMeature} + > + {children} + </motion.div> + </motion.div> + </div> + ); +}); + +const mapState = s => ({ + // proxies: getProxies(s) +}); +export default connect(mapState)(ProxyProvider); diff --git a/src/components/ProxyProvider.module.css b/src/components/ProxyProvider.module.css new file mode 100644 index 0000000..6668f67 --- /dev/null +++ b/src/components/ProxyProvider.module.css @@ -0,0 +1,43 @@ +.header { + display: flex; + align-items: center; + cursor: pointer; + + .arrow { + display: inline-flex; + transform: rotate(0deg); + transition: transform 0.3s; + &.isOpen { + transform: rotate(180deg); + } + + &:focus { + outline: var(--color-focus-blue) solid 1px; + } + } +} + +.updatedAt { + margin-bottom: 12px; + small { + color: #777; + } +} + +.body { + padding: 10px 15px; + @media (--breakpoint-not-small) { + padding: 10px 40px; + } +} + +.actionFooter { + display: flex; +} + +.refresh { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} diff --git a/src/components/ProxyProviderList.js b/src/components/ProxyProviderList.js new file mode 100644 index 0000000..2ae0fce --- /dev/null +++ b/src/components/ProxyProviderList.js @@ -0,0 +1,19 @@ +import React from 'react'; + +import ContentHeader from './ContentHeader'; +import ProxyProvider from './ProxyProvider'; + +function ProxyProviderList({ items }) { + return ( + <> + <ContentHeader title="Proxy Provider" /> + <div> + {items.map(item => ( + <ProxyProvider key={item.name} item={item} /> + ))} + </div> + </> + ); +} + +export default ProxyProviderList; diff --git a/src/components/Root.css b/src/components/Root.css index d611a02..3622d57 100644 --- a/src/components/Root.css +++ b/src/components/Root.css @@ -10,6 +10,13 @@ U+FEFF, U+FFFD; } +.relative { + position: relative; +} +/* .absolute { */ +/* position: absolute; */ +/* } */ + .border-left, .border-top, .border-bottom { @@ -113,6 +120,12 @@ body.light { --bg-modal: #fbfbfb; } +.flexCenter { + display: flex; + align-items: center; + justify-content: center; +} + /* TODO remove fabgrp in component css files */ .fabgrp { position: fixed; diff --git a/src/components/Root.js b/src/components/Root.js index 56c0be0..4147fd2 100644 --- a/src/components/Root.js +++ b/src/components/Root.js @@ -1,5 +1,6 @@ import React, { Suspense } from 'react'; -import { Provider } from 'm/store'; +import { Provider } from '../misc/store'; +import StateProvider from './StateProvider'; import { HashRouter as Router, Route } from 'react-router-dom'; import { hot } from 'react-hot-loader/root'; import Loading2 from 'c/Loading2'; @@ -36,30 +37,38 @@ const Rules = React.lazy(() => window.store = store; +const initialState = { + proxies: { + proxies: {}, + delay: {}, + groupNames: [] + } +}; + const Root = () => ( <ErrorBoundary> - <Provider store={store}> - <Router> - <div className={s0.app}> - <APIDiscovery /> - <Route path="/" render={props => <SideBar {...props} />} /> - <div className={s0.content}> - <Suspense fallback={<Loading2 />}> - <Route exact path="/" render={() => <Home />} /> - <Route exact path="/connections" component={Connections} /> - <Route exact path="/overview" render={() => <Home />} /> - <Route exact path="/configs" component={Config} /> - <Route exact path="/logs" component={Logs} /> - <Route exact path="/proxies" render={() => <Proxies />} /> - <Route exact path="/rules" render={() => <Rules />} /> - </Suspense> + <StateProvider initialState={initialState}> + <Provider store={store}> + <Router> + <div className={s0.app}> + <APIDiscovery /> + <Route path="/" render={props => <SideBar {...props} />} /> + <div className={s0.content}> + <Suspense fallback={<Loading2 />}> + <Route exact path="/" render={() => <Home />} /> + <Route exact path="/connections" component={Connections} /> + <Route exact path="/overview" render={() => <Home />} /> + <Route exact path="/configs" component={Config} /> + <Route exact path="/logs" component={Logs} /> + <Route exact path="/proxies" render={() => <Proxies />} /> + <Route exact path="/rules" render={() => <Rules />} /> + </Suspense> + </div> </div> - </div> - </Router> - </Provider> + </Router> + </Provider> + </StateProvider> </ErrorBoundary> ); -// <Route exact path="/__0" render={() => <StyleGuide />} /> -// <Route exact path="/__1" component={Loading} /> export default hot(Root); diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js new file mode 100644 index 0000000..adb1b24 --- /dev/null +++ b/src/components/StateProvider.js @@ -0,0 +1,79 @@ +import React from 'react'; +import produce, * as immer from 'immer'; + +const { + createContext, + memo, + useRef, + useEffect, + useCallback, + useContext, + useState +} = React; + +const StateContext = createContext(null); +const DispatchContext = createContext(null); + +export { immer }; + +export function useStoreState() { + return useContext(StateContext); +} + +export function useStoreDispatch() { + return useContext(DispatchContext); +} + +export default function Provider({ initialState, children }) { + const stateRef = useRef(initialState); + const [state, setState] = useState(initialState); + const getState = useCallback(() => stateRef.current, []); + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + window.getState2 = getState; + } + }, [getState]); + const dispatch = useCallback( + (actionId, fn, thunk) => { + // if (thunk) return thunk(dispatch, getState); + if (typeof actionId === 'function') return actionId(dispatch, getState); + + const stateNext = produce(getState(), fn); + if (stateNext !== stateRef.current) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log(actionId, stateNext); + } + stateRef.current = stateNext; + setState(stateNext); + } + }, + [getState] + ); + + return ( + <StateContext.Provider value={state}> + <DispatchContext.Provider value={dispatch}> + {children} + </DispatchContext.Provider> + </StateContext.Provider> + ); +} + +export function connect(mapStateToProps) { + return Component => { + const MemoComponent = memo(Component); + function Connected(props) { + const state = useContext(StateContext); + const dispatch = useContext(DispatchContext); + const mapped = mapStateToProps(state, props); + const nextProps = { + ...props, + ...mapped, + dispatch + }; + return <MemoComponent {...nextProps} />; + } + return Connected; + }; +} diff --git a/src/components/shared/Basic.js b/src/components/shared/Basic.js new file mode 100644 index 0000000..9d07a39 --- /dev/null +++ b/src/components/shared/Basic.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import s from './Basic.module.css'; + +export function SectionNameType({ name, type }) { + return ( + <h2 className={s.sectionNameType}> + <span>{name}</span> + <span>{type}</span> + </h2> + ); +} diff --git a/src/components/shared/Basic.module.css b/src/components/shared/Basic.module.css new file mode 100644 index 0000000..8e3e65c --- /dev/null +++ b/src/components/shared/Basic.module.css @@ -0,0 +1,14 @@ +h2.sectionNameType { + margin: 0; + font-size: 1.3em; + @media (--breakpoint-not-small) { + font-size: 1.5em; + } + + span:nth-child(2) { + font-size: 12px; + color: #777; + font-weight: normal; + margin: 0 0.3em; + } +} diff --git a/src/ducks/index.js b/src/ducks/index.js index 35ccc10..33e5067 100644 --- a/src/ducks/index.js +++ b/src/ducks/index.js @@ -1,7 +1,6 @@ import { combineReducers } from 'redux'; import app from './app'; import modals from './modals'; -import proxies from './proxies'; import rules from './rules'; import logs from './logs'; import configs from './configs'; @@ -9,7 +8,6 @@ import configs from './configs'; export default combineReducers({ app, modals, - proxies, rules, logs, configs diff --git a/src/ducks/proxies.js b/src/store/proxies.js index 3bc29d3..fe51368 100644 --- a/src/ducks/proxies.js +++ b/src/store/proxies.js @@ -1,5 +1,4 @@ -import * as proxiesAPI from 'a/proxies'; -import { getClashAPIConfig } from 'd/app'; +import * as proxiesAPI from '../api/proxies'; // see all types: // https://github.com/Dreamacro/clash/blob/master/constant/adapters.go @@ -12,52 +11,22 @@ const ProxyTypes = ['Shadowsocks', 'Snell', 'Socks5', 'Http', 'Vmess']; 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 || []; -const CompletedFetchProxies = 'proxies/CompletedFetchProxies'; -const OptimisticSwitchProxy = 'proxies/OptimisticSwitchProxy'; -const CompletedRequestDelayForProxy = 'proxies/CompletedRequestDelayForProxy'; - -function retrieveGroupNamesFrom(proxies) { - let groupNames = []; - let globalAll; - let proxyNames = []; - for (const prop in proxies) { - const p = proxies[prop]; - if (p.all && Array.isArray(p.all)) { - groupNames.push(prop); - if (prop === 'GLOBAL') { - globalAll = p.all; - } - } else if (ProxyTypes.indexOf(p.type) >= 0) { - proxyNames.push(prop); - } - } - if (globalAll) { - // Put GLOBAL in the end - globalAll.push('GLOBAL'); - // Sort groups according to its index in GLOBAL group - groupNames = groupNames - .map(name => [globalAll.indexOf(name), name]) - .sort((a, b) => a[0] - b[0]) - .map(group => group[1]); - } - return [groupNames, proxyNames]; -} - -export function fetchProxies() { +export function fetchProxies(apiConfig) { return async (dispatch, getState) => { - // TODO handle errors - - const state = getState(); - - const apiConfig = getClashAPIConfig(state); - // TODO show loading animation? - const json = await proxiesAPI.fetchProxies(apiConfig); - let { proxies = {} } = json; + const [proxiesData, providersData] = await Promise.all([ + proxiesAPI.fetchProxies(apiConfig), + proxiesAPI.fetchProviderProxies(apiConfig) + ]); + const [proxyProviders, providerProxies] = formatProxyProviders( + providersData.providers + ); + const proxies = { ...providerProxies, ...proxiesData.proxies }; const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies); - const delayPrev = getDelay(getState()); + const delayPrev = getDelay(getState()); const delayNext = { ...delayPrev }; for (let i = 0; i < proxyNames.length; i++) { @@ -75,17 +44,30 @@ export function fetchProxies() { } } - dispatch({ - type: CompletedFetchProxies, - payload: { proxies, groupNames, delay: delayNext } + dispatch('store/proxies#fetchProxies', s => { + s.proxies.proxies = proxies; + s.proxies.groupNames = groupNames; + s.proxies.delay = delayNext; + s.proxies.proxyProviders = proxyProviders; }); }; } -export function switchProxy(name1, name2) { - return async (dispatch, getState) => { - const apiConfig = getClashAPIConfig(getState()); - // TODO display error message +export function updateProviderByName(apiConfig, name) { + return async dispatch => { + try { + await proxiesAPI.updateProviderByName(apiConfig, name); + } catch (x) { + // ignore + } + // should be optimized + // but ¯\_(ツ)_/¯ + dispatch(fetchProxies(apiConfig)); + }; +} + +export function switchProxy(apiConfig, name1, name2) { + return async dispatch => { proxiesAPI .requestToSwitchProxy(apiConfig, name1, name2) .then( @@ -101,25 +83,20 @@ export function switchProxy(name1, name2) { } ) .then(() => { - // fetchProxies again - dispatch(fetchProxies()); + dispatch(fetchProxies(apiConfig)); }); // optimistic UI update - const proxiesCurr = getProxies(getState()); - const proxiesNext = { ...proxiesCurr }; - if (proxiesNext[name1] && proxiesNext[name1].now) { - proxiesNext[name1].now = name2; - } - dispatch({ - type: OptimisticSwitchProxy, - payload: { proxies: proxiesNext } + dispatch('store/proxies#switchProxy', s => { + const proxies = s.proxies.proxies; + if (proxies[name1] && proxies[name1].now) { + proxies[name1].now = name2; + } }); }; } -function requestDelayForProxyOnce(name) { +function requestDelayForProxyOnce(apiConfig, name) { return async (dispatch, getState) => { - const apiConfig = getClashAPIConfig(getState()); const res = await proxiesAPI.requestDelayForProxy(apiConfig, name); let error = ''; if (res.ok === false) { @@ -136,20 +113,19 @@ function requestDelayForProxyOnce(name) { } }; - dispatch({ - type: CompletedRequestDelayForProxy, - payload: { delay: delayNext } + dispatch('requestDelayForProxyOnce', s => { + s.proxies.delay = delayNext; }); }; } -export function requestDelayForProxy(name) { +export function requestDelayForProxy(apiConfig, name) { return async dispatch => { - await dispatch(requestDelayForProxyOnce(name)); + await dispatch(requestDelayForProxyOnce(apiConfig, name)); }; } -export function requestDelayAll() { +export function requestDelayAll(apiConfig) { return async (dispatch, getState) => { const state = getState(); const proxies = getProxies(state); @@ -160,25 +136,59 @@ export function requestDelayAll() { proxyNames.push(k); } }); - await Promise.all(proxyNames.map(p => dispatch(requestDelayForProxy(p)))); + await Promise.all( + proxyNames.map(p => dispatch(requestDelayForProxy(apiConfig, p))) + ); }; } -const initialState = { - proxies: {}, - delay: {}, - groupNames: [] -}; +function retrieveGroupNamesFrom(proxies) { + let groupNames = []; + let globalAll; + let proxyNames = []; + for (const prop in proxies) { + const p = proxies[prop]; + if (p.all && Array.isArray(p.all)) { + groupNames.push(prop); + if (prop === 'GLOBAL') { + globalAll = p.all; + } + } else if (ProxyTypes.indexOf(p.type) >= 0) { + proxyNames.push(prop); + } + } + if (globalAll) { + // Put GLOBAL in the end + globalAll.push('GLOBAL'); + // Sort groups according to its index in GLOBAL group + groupNames = groupNames + .map(name => [globalAll.indexOf(name), name]) + .sort((a, b) => a[0] - b[0]) + .map(group => group[1]); + } + return [groupNames, proxyNames]; +} -export default function reducer(state = initialState, { type, payload }) { - switch (type) { - case CompletedRequestDelayForProxy: - case OptimisticSwitchProxy: - case CompletedFetchProxies: { - return { ...state, ...payload }; +function formatProxyProviders(providersInput) { + const keys = Object.keys(providersInput); + const providers = []; + const proxies = {}; + for (let i = 0; i < keys.length; i++) { + const provider = providersInput[keys[i]]; + if (provider.name === 'default' || provider.vehicleType === 'Compatible') + continue; + const proxiesArr = provider.proxies; + const names = []; + for (let j = 0; j < proxiesArr.length; j++) { + const proxy = proxiesArr[j]; + proxies[proxy.name] = proxy; + names.push(proxy.name); } - default: - return state; + // mutate directly + provider.proxies = names; + providers.push(provider); } + + return [providers, proxies]; } |
