From b42988d58b0c60ff96444364869b693775274e26 Mon Sep 17 00:00:00 2001 From: haishan Date: Wed, 4 Oct 2023 13:57:17 +0800 Subject: [PATCH] package pnpm-lock fetch mock Config Root APIConfig Backend BackendList AppConfigSideEffect custom utils --- package.json | 1 + pnpm-lock.yaml | 13 +++ src/api/fetch.ts | 6 + src/api/mock.ts | 70 ++++++++++++ src/components/Config.tsx | 2 +- src/components/Root.tsx | 8 +- .../{ => backend}/APIConfig.module.scss | 0 src/components/{ => backend}/APIConfig.tsx | 103 ++++++++--------- src/components/backend/Backend.tsx | 4 +- .../{ => backend}/BackendList.module.scss | 4 + src/components/{ => backend}/BackendList.tsx | 107 +++++++++++------- src/components/fn/AppConfigSideEffect.tsx | 4 +- src/custom.d.ts | 1 + src/misc/utils.ts | 3 + 14 files changed, 223 insertions(+), 103 deletions(-) create mode 100644 src/api/fetch.ts create mode 100644 src/api/mock.ts rename src/components/{ => backend}/APIConfig.module.scss (100%) rename src/components/{ => backend}/APIConfig.tsx (65%) rename src/components/{ => backend}/BackendList.module.scss (96%) rename src/components/{ => backend}/BackendList.tsx (57%) diff --git a/package.json b/package.json index 9405e5ece..ae8b8d99b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-tiny-fab": "4.0.4", "react-window": "^1.8.9", "reselect": "4.1.8", + "sonner": "^1.0.3", "tslib": "2.6.2", "use-asset": "1.0.4", "workbox-core": "7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c72b94992..2ce30c56d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ dependencies: reselect: specifier: 4.1.8 version: 4.1.8 + sonner: + specifier: ^1.0.3 + version: 1.0.3(react-dom@18.2.0)(react@18.2.0) tslib: specifier: 2.6.2 version: 2.6.2 @@ -5046,6 +5049,16 @@ packages: engines: {node: '>=8'} dev: true + /sonner@1.0.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-hBoA2zKuYW3lUnpx4K0vAn8j77YuYiwvP9sLQfieNS2pd5FkT20sMyPTDJnl9S+5T27ZJbwQRPiujwvDBwhZQg==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} diff --git a/src/api/fetch.ts b/src/api/fetch.ts new file mode 100644 index 000000000..9436b7c91 --- /dev/null +++ b/src/api/fetch.ts @@ -0,0 +1,6 @@ +export function req(url: string, init: RequestInit) { + if (import.meta.env.DEV) { + return import('./mock').then((mod) => mod.mock(url, init)); + } + return fetch(url, init); +} diff --git a/src/api/mock.ts b/src/api/mock.ts new file mode 100644 index 000000000..5381aa361 --- /dev/null +++ b/src/api/mock.ts @@ -0,0 +1,70 @@ +const MOCK_HANDLERS = [ + { + key: 'GET/', + enabled: false, + handler: (_u: string, _i: RequestInit) => { + // throw new Error(); + return deserializeError(); + // return json({ hello: 'clash' }); + }, + }, + { + key: 'GET/configs', + enabled: false, + handler: (_u: string, _i: RequestInit) => + json({ + port: 0, + 'socks-port': 7891, + 'redir-port': 0, + 'tproxy-port': 0, + 'mixed-port': 7890, + 'allow-lan': true, + 'bind-address': '*', + mode: 'rule', + 'log-level': 'info', + authentication: [], + ipv6: false, + }), + }, +]; + +export async function mock(url: string, init: RequestInit) { + const method = init.method || 'GET'; + const pathname = new URL(url).pathname; + const key = `${method}${pathname}`; + const item = MOCK_HANDLERS.find((h) => { + if (h.enabled && h.key === key) return h; + }); + if (item) { + console.warn('Using mocked API', key); + return (await item?.handler(url, init)) as Response; + } + return fetch(url, init); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function json(data: T) { + await sleep(1); + return { + ok: true, + json: async () => { + await sleep(16); + return data; + }, + }; +} + +async function deserializeError() { + await sleep(1); + return { + ok: true, + json: async () => { + await sleep(16); + throw new Error(); + }, + }; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 9345b9482..9dd794304 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -248,7 +248,7 @@ function Config({ configs }: ConfigImplProps) { OptionComponent={TrafficChartSample} optionPropsList={propsList} selectedIndex={selectedChartStyleIndex} - onChange={setSelectedChartStyleIndex} + onChange={(v: string) => setSelectedChartStyleIndex(parseInt(v, 10))} />
diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 8b85ec2c5..089f130fb 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -6,6 +6,7 @@ import { useAtom } from 'jotai'; import * as React from 'react'; import { RouteObject } from 'react-router'; import { HashRouter as Router, useRoutes } from 'react-router-dom'; +import { Toaster } from 'sonner'; import { About } from 'src/components/about/About'; import Loading from 'src/components/Loading'; import { Head } from 'src/components/shared/Head'; @@ -78,7 +79,12 @@ function App() { function AppShell({ children }: { children: React.ReactNode }) { const [pureBlackDark] = useAtom(darkModePureBlackToggleAtom); const clazz = cx(s0.app, { pureBlackDark }); - return
{children}
; + return ( + <> + +
{children}
+ + ); } const Root = () => ( diff --git a/src/components/APIConfig.module.scss b/src/components/backend/APIConfig.module.scss similarity index 100% rename from src/components/APIConfig.module.scss rename to src/components/backend/APIConfig.module.scss diff --git a/src/components/APIConfig.tsx b/src/components/backend/APIConfig.tsx similarity index 65% rename from src/components/APIConfig.tsx rename to src/components/backend/APIConfig.tsx index 8c37adfc6..ce864f503 100644 --- a/src/components/APIConfig.tsx +++ b/src/components/backend/APIConfig.tsx @@ -1,20 +1,21 @@ import { useAtom } from 'jotai'; import * as React from 'react'; import { fetchConfigs } from 'src/api/configs'; -import { BackendList } from 'src/components/BackendList'; import { clashAPIConfigsAtom, findClashAPIConfigIndex } from 'src/store/app'; import { ClashAPIConfig } from 'src/types'; +import Field from '$src/components//Field'; +import { BackendList } from '$src/components/backend/BackendList'; +import Button from '$src/components/Button'; +import SvgYacd from '$src/components/SvgYacd'; + import s0 from './APIConfig.module.scss'; -import Button from './Button'; -import Field from './Field'; -import SvgYacd from './SvgYacd'; const { useState, useRef, useCallback, useEffect } = React; const Ok = 0; // eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => { }; +const noop = () => {}; export default function APIConfig() { const [baseURL, setBaseURL] = useState(''); @@ -23,7 +24,6 @@ export default function APIConfig() { const [errMsg, setErrMsg] = useState(''); const userTouchedFlagRef = useRef(false); - const contentEl = useRef(null); const handleInputOnChange = useCallback>((e) => { userTouchedFlagRef.current = true; @@ -62,20 +62,10 @@ export default function APIConfig() { }); }, [baseURL, secret, metaLabel, apiConfigs, setApiConfigs]); - const handleContentOnKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ( - e.target instanceof Element && - (!e.target.tagName || e.target.tagName.toUpperCase() !== 'INPUT') - ) { - return; - } - if (e.key !== 'Enter') return; - - onConfirm(); - }, - [onConfirm], - ); + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + onConfirm(); + }, [onConfirm]) const detectApiServer = async () => { // if there is already a clash API server at `/`, just use it as default value @@ -91,8 +81,7 @@ export default function APIConfig() { }, []); return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
+
-
-
- - +
+
+
+ + +
+ {errMsg ?
{errMsg}
: null} +
+ +
- {errMsg ?
{errMsg}
: null} -
- +
+
-
-
-
+
diff --git a/src/components/backend/Backend.tsx b/src/components/backend/Backend.tsx index 3bdfeec2e..197487cf8 100644 --- a/src/components/backend/Backend.tsx +++ b/src/components/backend/Backend.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import APIConfig from '../APIConfig'; import { ThemeSwitcher } from '../shared/ThemeSwitcher'; +import APIConfig from './APIConfig'; export function Backend() { return ( @@ -11,7 +11,7 @@ export function Backend() { style={{ position: 'fixed', padding: 16, - right: 0, + left: 0, bottom: 0, }} > diff --git a/src/components/BackendList.module.scss b/src/components/backend/BackendList.module.scss similarity index 96% rename from src/components/BackendList.module.scss rename to src/components/backend/BackendList.module.scss index df2305a8e..54c672fc4 100644 --- a/src/components/BackendList.module.scss +++ b/src/components/backend/BackendList.module.scss @@ -19,6 +19,10 @@ column-gap: 10px; border: 1px solid var(--bg-near-transparent); + &.isSelected { + border-color: #387cec; + } + .right { display: grid; column-gap: 10px; diff --git a/src/components/BackendList.tsx b/src/components/backend/BackendList.tsx similarity index 57% rename from src/components/BackendList.tsx rename to src/components/backend/BackendList.tsx index bf8d45d86..021a46ec4 100644 --- a/src/components/BackendList.tsx +++ b/src/components/backend/BackendList.tsx @@ -2,8 +2,13 @@ import cx from 'clsx'; import { useAtom } from 'jotai'; import * as React from 'react'; import { Eye, EyeOff, X as Close } from 'react-feather'; +import { useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { req } from '$src/api/fetch'; import { useToggle } from '$src/hooks/basic'; +import { getURLAndInit } from '$src/misc/request-helper'; +import { noop } from '$src/misc/utils'; import { clashAPIConfigsAtom, findClashAPIConfigIndex, @@ -13,11 +18,11 @@ import { ClashAPIConfig } from '$src/types'; import s from './BackendList.module.scss'; +const PASS_THRU_ERROR = {}; + export function BackendList() { const [apiConfigs, setApiConfigs] = useAtom(clashAPIConfigsAtom); - const [selectedClashAPIConfigIndex, setSelectedClashAPIConfigIndex] = useAtom( - selectedClashAPIConfigIndexAtom, - ); + const [currIdx, setCurrIdx] = useAtom(selectedClashAPIConfigIndexAtom); const removeClashAPIConfig = React.useCallback( (conf: ClashAPIConfig) => { const idx = findClashAPIConfigIndex(apiConfigs, conf); @@ -25,45 +30,61 @@ export function BackendList() { apiConfigs.splice(idx, 1); return [...apiConfigs]; }); - if (idx === selectedClashAPIConfigIndex) { - setSelectedClashAPIConfigIndex(0); - } else if (idx < selectedClashAPIConfigIndex) { - setSelectedClashAPIConfigIndex(selectedClashAPIConfigIndex - 1); + if (idx === currIdx) { + setCurrIdx(0); + } else if (idx < currIdx) { + setCurrIdx(currIdx - 1); } }, - [apiConfigs, selectedClashAPIConfigIndex, setApiConfigs, setSelectedClashAPIConfigIndex], + [apiConfigs, currIdx, setApiConfigs, setCurrIdx], ); - const selectClashAPIConfig = React.useCallback( - (conf: ClashAPIConfig) => { - const idx = findClashAPIConfigIndex(apiConfigs, conf); - const curr = selectedClashAPIConfigIndex; - if (curr !== idx) { - setSelectedClashAPIConfigIndex(idx); - } + const navigate = useNavigate(); - // manual clean up is too complex - // we just reload the app - try { - window.location.href = '/'; - } catch (err) { - // ignore - } - }, - [apiConfigs, selectedClashAPIConfigIndex, setSelectedClashAPIConfigIndex], - ); - - const onRemove = React.useCallback( - (conf: ClashAPIConfig) => { - removeClashAPIConfig(conf); - }, - [removeClashAPIConfig], - ); const onSelect = React.useCallback( - (conf: ClashAPIConfig) => { - selectClashAPIConfig(conf); + async (conf: ClashAPIConfig) => { + const idx = findClashAPIConfigIndex(apiConfigs, conf); + const { url, init } = getURLAndInit(apiConfigs[idx]); + await req(url, init) + .then( + (res) => res.json(), + (err) => { + console.log(err); + toast.error('Failed to connect'); + throw PASS_THRU_ERROR; + }, + ) + .then( + (data) => { + if (typeof data['hello'] !== 'string') { + console.log('Response:', data); + toast.error('Unexpected response'); + throw PASS_THRU_ERROR; + } + }, + (err) => { + if (err === PASS_THRU_ERROR) throw PASS_THRU_ERROR; + console.log(err); + toast.error('Unexpected response'); + throw PASS_THRU_ERROR; + }, + ) + .then(() => { + if (currIdx === idx) { + navigate('/', { replace: true }); + } else { + setCurrIdx(idx); + // manual clean up is too complex + // we just reload the app + try { + window.location.href = '/'; + } catch (err) { + // ignore + } + } + }, noop); }, - [selectClashAPIConfig], + [apiConfigs, currIdx, setCurrIdx, navigate], ); return ( @@ -72,13 +93,13 @@ export function BackendList() { {apiConfigs.map((item, idx) => { return (
  • @@ -109,9 +130,13 @@ function Item({ return ( <> - + {disableRemove ? ( + + ) : ( + + )}
    {conf.metaLabel ? ( diff --git a/src/components/fn/AppConfigSideEffect.tsx b/src/components/fn/AppConfigSideEffect.tsx index c8f6dc769..d4e87d4ed 100644 --- a/src/components/fn/AppConfigSideEffect.tsx +++ b/src/components/fn/AppConfigSideEffect.tsx @@ -2,7 +2,7 @@ import { useAtom } from 'jotai'; import { useEffect } from 'react'; import { saveState } from '$src/misc/storage'; -import { debounce } from '$src/misc/utils'; +import { throttle } from '$src/misc/utils'; import { autoCloseOldConnsAtom, clashAPIConfigsAtom, @@ -23,7 +23,7 @@ function save0() { if (stateRef) saveState(stateRef); } -const save = debounce(save0, 500); +const save = throttle(save0, 500); export function AppConfigSideEffect() { const [selectedClashAPIConfigIndex] = useAtom(selectedClashAPIConfigIndexAtom); diff --git a/src/custom.d.ts b/src/custom.d.ts index a6a8ace79..1aa5779ce 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -1,5 +1,6 @@ /// /// +/// // for css modules declare module '*.module.css' { diff --git a/src/misc/utils.ts b/src/misc/utils.ts index 949702638..1b3e7c1c3 100644 --- a/src/misc/utils.ts +++ b/src/misc/utils.ts @@ -33,3 +33,6 @@ export function pad0(number: number | string, len: number): string { } return output; } + +// eslint-disable-next-line @typescript-eslint/no-empty-function +export const noop = () => {};