diff --git a/.eslintrc.yml b/.eslintrc.yml index 201d0163c..c4c7605ba 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -4,8 +4,13 @@ parserOptions: project: tsconfig.json sourceType: module +settings: + react: + version: detect + plugins: - simple-import-sort + - unused-imports - jsx-a11y extends: @@ -30,8 +35,14 @@ rules: '@typescript-eslint/no-explicit-any': 'off' '@typescript-eslint/camelcase': 'off' 'no-use-before-define': 'off' - '@typescript-eslint/no-unused-vars': - - 'error' + # '@typescript-eslint/no-unused-vars': + # - 'error' + # - argsIgnorePattern: '^_' + ## disable '@typescript-eslint/no-unused-vars' and use 'unused-imports' plugin/rules instead + '@typescript-eslint/no-unused-vars': 'off' + 'unused-imports/no-unused-imports': 'error' + 'unused-imports/no-unused-vars': + - 'warn' - argsIgnorePattern: '^_' '@typescript-eslint/no-use-before-define': - error diff --git a/package.json b/package.json index 2aa9c5c50..7aaad2da2 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "yacd", "version": "0.3.8", "description": "Yet another Clash dashboard", + "type": "module", "prettier": { "printWidth": 100, "singleQuote": true @@ -12,7 +13,7 @@ "start": "vite", "build": "vite build", "serve": "vite preview", - "pretty": "prettier --write 'src/**/*.{js,scss,ts,tsx,md}'", + "pretty": "prettier --log-level warn --write 'src/**/*.{js,scss,ts,tsx,md}'", "fmt": "pnpm lint && pnpm pretty" }, "browserslist": [ @@ -44,11 +45,13 @@ "i18next-browser-languagedetector": "7.1.0", "immer": "10.0.2", "invariant": "^2.2.4", + "is-network-error": "1.0.0", "jotai": "^2.4.3", "lodash-es": "^4.17.21", "modern-normalize": "2.0.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-error-boundary": "4.0.11", "react-feather": "^2.0.10", "react-i18next": "13.2.2", "react-icons": "4.11.0", @@ -89,12 +92,14 @@ "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-unused-imports": "3.0.0", "postcss": "8.4.31", "postcss-import": "15.1.0", "postcss-simple-vars": "^7.0.1", "prettier": "3.0.3", "resize-observer-polyfill": "^1.5.1", "sass": "1.68.0", + "tailwindcss": "3.3.3", "typescript": "5.2.2", "vite": "4.4.9", "vite-plugin-pwa": "0.16.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b39b1ccbd..ffc7eed65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ dependencies: invariant: specifier: ^2.2.4 version: 2.2.4 + is-network-error: + specifier: 1.0.0 + version: 1.0.0 jotai: specifier: ^2.4.3 version: 2.4.3(@types/react@18.2.23)(react@18.2.0) @@ -73,6 +76,9 @@ dependencies: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: 4.0.11 + version: 4.0.11(react@18.2.0) react-feather: specifier: ^2.0.10 version: 2.0.10(react@18.2.0) @@ -189,6 +195,9 @@ devDependencies: eslint-plugin-simple-import-sort: specifier: ^10.0.0 version: 10.0.0(eslint@8.50.0) + eslint-plugin-unused-imports: + specifier: 3.0.0 + version: 3.0.0(@typescript-eslint/eslint-plugin@6.7.3)(eslint@8.50.0) postcss: specifier: 8.4.31 version: 8.4.31 @@ -207,6 +216,9 @@ devDependencies: sass: specifier: 1.68.0 version: 1.68.0 + tailwindcss: + specifier: 3.3.3 + version: 3.3.3 typescript: specifier: 5.2.2 version: 5.2.2 @@ -224,6 +236,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + dev: true + /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} @@ -2785,6 +2802,10 @@ packages: color-convert: 2.0.1 dev: true + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2793,6 +2814,10 @@ packages: picomatch: 2.3.1 dev: true + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -3047,6 +3072,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: true + /caniuse-lite@1.0.30001541: resolution: {integrity: sha512-bLOsqxDgTqUBkzxbNlSBt8annkDpQB9NdzdTbO2ooJ+eC/IQcvDspDc058g84ejCelF7vHUx57KIOjEecOHXaw==} dev: true @@ -3120,6 +3150,11 @@ packages: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: true + /common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} @@ -3164,6 +3199,12 @@ packages: engines: {node: '>=8'} dev: true + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} @@ -3270,6 +3311,10 @@ packages: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} dev: false + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3277,6 +3322,10 @@ packages: path-type: 4.0.0 dev: true + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true + /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3776,6 +3825,26 @@ packages: eslint: 8.50.0 dev: true + /eslint-plugin-unused-imports@3.0.0(@typescript-eslint/eslint-plugin@6.7.3)(eslint@8.50.0): + resolution: {integrity: sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^6.0.0 + eslint: ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + dependencies: + '@typescript-eslint/eslint-plugin': 6.7.3(@typescript-eslint/parser@6.7.3)(eslint@8.50.0)(typescript@5.2.2) + eslint: 8.50.0 + eslint-rule-composer: 0.3.0 + dev: true + + /eslint-rule-composer@0.3.0: + resolution: {integrity: sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==} + engines: {node: '>=4.0.0'} + dev: true + /eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4084,6 +4153,17 @@ packages: is-glob: 4.0.3 dev: true + /glob@7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -4404,6 +4484,11 @@ packages: engines: {node: '>= 0.4'} dev: true + /is-network-error@1.0.0: + resolution: {integrity: sha512-P3fxi10Aji2FZmHTrMPSNFbNC6nnp4U5juPAIjXPHkUNubi4+qK7vvdsaNpAUwXslhYm9oyjEYTxs1xd/+Ph0w==} + engines: {node: '>=16'} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -4540,6 +4625,11 @@ packages: supports-color: 7.2.0 dev: true + /jiti@1.20.0: + resolution: {integrity: sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==} + hasBin: true + dev: true + /jotai@2.4.3(@types/react@18.2.23)(react@18.2.0): resolution: {integrity: sha512-CSAHX9LqWG5WCrU8OgBoZbBJ+Bo9rQU0mPusEF4e0CZ/SNFgurG26vb3UpgvCSJZgYVcUQNiUBM5q86PA8rstQ==} engines: {node: '>=12.20.0'} @@ -4670,6 +4760,15 @@ packages: type-check: 0.4.0 dev: true + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4773,6 +4872,14 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: true + /nanoid@3.3.6: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4801,6 +4908,11 @@ packages: resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + /object-inspect@1.12.2: resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} dev: true @@ -4989,6 +5101,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + dev: true + /postcss-import@15.1.0(postcss@8.4.31): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -5001,6 +5118,51 @@ packages: resolve: 1.22.1 dev: true + /postcss-js@4.0.1(postcss@8.4.31): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.31 + dev: true + + /postcss-load-config@4.0.1(postcss@8.4.31): + resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + postcss: 8.4.31 + yaml: 2.3.2 + dev: true + + /postcss-nested@6.0.1(postcss@8.4.31): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.31 + postcss-selector-parser: 6.0.13 + dev: true + + /postcss-selector-parser@6.0.13: + resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: true + /postcss-simple-vars@7.0.1(postcss@8.4.31): resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} engines: {node: '>=14.0'} @@ -5076,6 +5238,15 @@ packages: scheduler: 0.23.0 dev: false + /react-error-boundary@4.0.11(react@18.2.0): + resolution: {integrity: sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.23.1 + react: 18.2.0 + dev: false + /react-feather@2.0.10(react@18.2.0): resolution: {integrity: sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==} peerDependencies: @@ -5726,6 +5897,20 @@ packages: engines: {node: '>=8'} dev: true + /sucrase@3.34.0: + resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} + engines: {node: '>=8'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 7.1.6 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + dev: true + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5745,6 +5930,37 @@ packages: engines: {node: '>= 0.4'} dev: true + /tailwindcss@3.3.3: + resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.5.3 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.1 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.20.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.31 + postcss-import: 15.1.0(postcss@8.4.31) + postcss-js: 4.0.1(postcss@8.4.31) + postcss-load-config: 4.0.1(postcss@8.4.31) + postcss-nested: 6.0.1(postcss@8.4.31) + postcss-selector-parser: 6.0.13 + resolve: 1.22.6 + sucrase: 3.34.0 + transitivePeerDependencies: + - ts-node + dev: true + /temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -5775,6 +5991,19 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: true + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: true + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -5802,6 +6031,10 @@ packages: typescript: 5.2.2 dev: true + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: true + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: @@ -5990,6 +6223,10 @@ packages: react: 18.2.0 dev: false + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + /vite-plugin-pwa@0.16.5(vite@4.4.9)(workbox-build@7.0.0)(workbox-window@7.0.0): resolution: {integrity: sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==} engines: {node: '>=16.0.0'} @@ -6288,6 +6525,11 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yaml@2.3.2: + resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==} + engines: {node: '>= 14'} + dev: true + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/postcss.config.js b/postcss.config.js index 6c730217a..2e916eef8 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,13 +1,11 @@ -'use strict'; - // '--breakpoint-not-small': 'screen and (min-width: 30em)', // '--breakpoint-medium': 'screen and (min-width: 30em) and (max-width: 60em)', // '--breakpoint-large': 'screen and (min-width: 60em)', - -module.exports = { - plugins: [ - require('postcss-import')(), - require('postcss-simple-vars')(), - require('autoprefixer')(), - ], +export default { + plugins: { + 'postcss-import': {}, + 'postcss-simple-vars': {}, + tailwindcss: {}, + autoprefixer: {}, + }, }; diff --git a/src/api/configs.ts b/src/api/configs.ts index 7822b38c7..1a239bcd4 100644 --- a/src/api/configs.ts +++ b/src/api/configs.ts @@ -1,19 +1,17 @@ import { getURLAndInit } from 'src/misc/request-helper'; import { ClashGeneralConfig } from 'src/store/types'; import { ClashAPIConfig } from 'src/types'; -import { req } from './fetch'; + +import { handleFetchError, query, QueryCtx, req } from './fetch'; const endpoint = '/configs'; -export async function fetchConfigs2(ctx: { queryKey: readonly [string, ClashAPIConfig] }) { - const endpoint = ctx.queryKey[0]; - const apiConfig = ctx.queryKey[1]; - const { url, init } = getURLAndInit(apiConfig); - const res = await fetch(url + endpoint, init); - if (!res.ok) { +export async function fetchConfigs2(ctx: QueryCtx) { + const json = await query(ctx); + if (!json) { throw new Error('TODO'); } - return await res.json(); + return json; } export function updateConfigs(apiConfig: ClashAPIConfig) { @@ -26,7 +24,11 @@ export function updateConfigs(apiConfig: ClashAPIConfig) { export async function fetchConfigs(apiConfig: ClashAPIConfig) { const { url, init } = getURLAndInit(apiConfig); - return await req(url + endpoint, init); + try { + return await req(url + endpoint, init); + } catch (err) { + handleFetchError(err, { endpoint, apiConfig }); + } } // TODO support PUT /configs diff --git a/src/api/connections.ts b/src/api/connections.ts index a5949d421..6e2f6f3a2 100644 --- a/src/api/connections.ts +++ b/src/api/connections.ts @@ -56,13 +56,13 @@ export function fetchData(apiConfig: ClashAPIConfig, listener?: unknown): Unsubs if (ws && ws.readyState <= WebSocket.OPEN) { if (listener) return subscribe(listener); return; - }; + } const url = buildWebSocketURL(apiConfig, endpoint); ws = new WebSocket(url); const onFrozen = () => { - if (ws.readyState <= WebSocket.OPEN) ws.close() + if (ws.readyState <= WebSocket.OPEN) ws.close(); }; const onResume = () => { if (ws.readyState <= WebSocket.OPEN) return; diff --git a/src/api/fetch.ts b/src/api/fetch.ts index 9436b7c91..147dfed5a 100644 --- a/src/api/fetch.ts +++ b/src/api/fetch.ts @@ -1,6 +1,79 @@ +import isNetworkError from 'is-network-error'; + +import { + YacdBackendGeneralError, + YacdBackendUnauthorizedError, + YacdFetchNetworkError, +} from '$src/misc/errors'; +import { getURLAndInit } from '$src/misc/request-helper'; +import { ClashAPIConfig, FetchCtx } from '$src/types'; + +export type QueryCtx = { + queryKey: readonly [string, ClashAPIConfig]; +}; + 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); } + +export async function query(ctx: QueryCtx) { + const endpoint = ctx.queryKey[0]; + const apiConfig = ctx.queryKey[1]; + const { url, init } = getURLAndInit(apiConfig); + + let res: Response; + try { + res = await req(url + endpoint, init); + } catch (err) { + handleFetchError(err, { endpoint, apiConfig }); + } + await validateFetchResponse(res, { endpoint, apiConfig }); + if (res.ok) { + return await res.json(); + } + // can return undefined +} + +export function handleFetchError(err: unknown, ctx: FetchCtx) { + if (isNetworkError(err)) throw new YacdFetchNetworkError('', ctx); + throw err; +} + +async function validateFetchResponse(res: Response, ctx: FetchCtx) { + if (res.status === 401) throw new YacdBackendUnauthorizedError('', ctx); + if (!res.ok) + throw new YacdBackendGeneralError('', { + ...ctx, + response: await simplifyRes(res), + }); + return res; +} + +export type SimplifiedResponse = { + status: number; + headers: string[]; + data?: any; +}; + +async function simplifyRes(res: Response): Promise { + const headers: string[] = []; + for (const [k, v] of res.headers) { + headers.push(`${k}: ${v}`); + } + + let data: any; + try { + data = await res.text(); + } catch (e) { + // ignore + } + + return { + status: res.status, + headers, + data, + }; +} diff --git a/src/api/mock.ts b/src/api/mock.ts index 1610c8234..eb8284fc5 100644 --- a/src/api/mock.ts +++ b/src/api/mock.ts @@ -10,8 +10,11 @@ const MOCK_HANDLERS = [ }, { key: 'GET/configs', - enabled: true, - handler: (_u: string, _i: RequestInit) => json(makeConfig()), + enabled: false, + handler: (_u: string, _i: RequestInit) => { + return apiError('{"name": "hello"}'); + // return json(makeConfig()); + }, }, { key: 'GET/notfound', @@ -47,6 +50,22 @@ async function json(data: T) { }; } +async function apiError(data: T) { + await sleep(1); + const headers = new Headers(); + headers.append('x-test-1', 'test-1'); + headers.append('x-test-2', 'test-3'); + return { + ok: false, + status: 400, + headers, + text: async () => { + await sleep(16); + return data; + }, + }; +} + async function deserializeError() { await sleep(1); return { diff --git a/src/api/rule-provider.ts b/src/api/rule-provider.ts index 5ecd61ea3..e219eb5ca 100644 --- a/src/api/rule-provider.ts +++ b/src/api/rule-provider.ts @@ -1,6 +1,8 @@ import { getURLAndInit } from 'src/misc/request-helper'; import { ClashAPIConfig } from 'src/types'; +import { query, QueryCtx } from './fetch'; + export type RuleProvider = RuleProviderAPIItem & { idx: number }; export type RuleProviderAPIItem = { @@ -31,20 +33,8 @@ function normalizeAPIResponse(data: RuleProviderAPIData) { return { byName, names }; } -export async function fetchRuleProviders(endpoint: string, apiConfig: ClashAPIConfig) { - const { url, init } = getURLAndInit(apiConfig); - - let data = { providers: {} }; - try { - const res = await fetch(url + endpoint, init); - if (res.ok) { - data = await res.json(); - } - } catch (err) { - // log and ignore - // eslint-disable-next-line no-console - console.log('failed to GET /providers/rules', err); - } +export async function fetchRuleProviders(ctx: QueryCtx) { + const data = (await query(ctx)) || { providers: {} }; return normalizeAPIResponse(data); } diff --git a/src/api/rules.ts b/src/api/rules.ts index cfe1b07fe..d54583408 100644 --- a/src/api/rules.ts +++ b/src/api/rules.ts @@ -1,8 +1,7 @@ import invariant from 'invariant'; -import { getURLAndInit } from 'src/misc/request-helper'; import { ClashAPIConfig } from 'src/types'; -// const endpoint = '/rules'; +import { query } from './fetch'; type RuleItem = RuleAPIItem & { id: number }; @@ -22,18 +21,7 @@ function normalizeAPIResponse(json: { rules: Array }): Array ({ ...r, id: i })); } -export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { - let json = { rules: [] }; - try { - const { url, init } = getURLAndInit(apiConfig); - const res = await fetch(url + endpoint, init); - if (res.ok) { - json = await res.json(); - } - } catch (err) { - // log and ignore - // eslint-disable-next-line no-console - console.log('failed to fetch rules', err); - } +export async function fetchRules(ctx: { queryKey: readonly [string, ClashAPIConfig] }) { + const json = (await query(ctx)) || { rules: [] }; return normalizeAPIResponse(json); } diff --git a/src/api/traffic.ts b/src/api/traffic.ts index 6af2858fc..93f8e3c05 100644 --- a/src/api/traffic.ts +++ b/src/api/traffic.ts @@ -51,7 +51,7 @@ export function fetchData(apiConfig: ClashAPIConfig) { ws = new WebSocket(url); const onFrozen = () => { - if (ws.readyState <= WebSocket.OPEN) ws.close() + if (ws.readyState <= WebSocket.OPEN) ws.close(); }; const onResume = () => { @@ -68,12 +68,12 @@ export function fetchData(apiConfig: ClashAPIConfig) { document.addEventListener('freeze', onFrozen, { capture: true, once: true }); document.addEventListener('resume', onResume, { capture: true, once: true }); - ws.addEventListener('error', function(_ev) { + ws.addEventListener('error', function (_ev) { console.log('error', _ev); // }); // ws.addEventListener('close', (_ev) => {}); - ws.addEventListener('message', function(event) { + ws.addEventListener('message', function (event) { parseAndAppend(event.data); }); return traffic; diff --git a/src/api/version.ts b/src/api/version.ts index bbb3be456..e1d4e7b89 100644 --- a/src/api/version.ts +++ b/src/api/version.ts @@ -1,26 +1,11 @@ -import { getURLAndInit } from 'src/misc/request-helper'; -import { ClashAPIConfig } from 'src/types'; +import { query, QueryCtx } from './fetch'; type VersionData = { version?: string; premium?: boolean; }; -export async function fetchVersion( - endpoint: string, - apiConfig: ClashAPIConfig, -): Promise { - let json = {}; - try { - const { url, init } = getURLAndInit(apiConfig); - const res = await fetch(url + endpoint, init); - if (res.ok) { - json = await res.json(); - } - } catch (err) { - // log and ignore - // eslint-disable-next-line no-console - console.log(`failed to fetch ${endpoint}`, err); - } +export async function fetchVersion(ctx: QueryCtx): Promise { + const json = (await query(ctx)) || {}; return json; } diff --git a/src/app.tsx b/src/app.tsx index d1149ee0f..88a7152fa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,4 +1,4 @@ -import 'modern-normalize/modern-normalize.css'; +// import 'modern-normalize/modern-normalize.css'; import './misc/i18n'; import '@fontsource/roboto-mono/latin-400.css'; import '@fontsource/inter/latin-400.css'; diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 9dd794304..35e7ebef6 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -19,7 +19,7 @@ import { ClashGeneralConfig } from '$src/store/types'; import Button from './Button'; import s0 from './Config.module.scss'; -import ContentHeader from './ContentHeader'; +import { ContentHeader } from './ContentHeader'; import { ToggleInput } from './form/Toggle'; import Input, { SelfControlledInput } from './Input'; import { Selection2 } from './Selection'; diff --git a/src/components/Connections.tsx b/src/components/Connections.tsx index 2cdfb72f8..04415688d 100644 --- a/src/components/Connections.tsx +++ b/src/components/Connections.tsx @@ -13,7 +13,7 @@ import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import s from './Connections.module.scss'; import ConnectionTable from './ConnectionTable'; import { MutableConnRefCtx } from './conns/ConnCtx'; -import ContentHeader from './ContentHeader'; +import { ContentHeader } from './ContentHeader'; import ModalCloseAllConnections from './ModalCloseAllConnections'; import { Action, Fab, position as fabPosition } from './shared/Fab'; import SvgYacd from './SvgYacd'; diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss index 21ef2af30..33fe045e7 100644 --- a/src/components/ContentHeader.module.scss +++ b/src/components/ContentHeader.module.scss @@ -7,6 +7,7 @@ .h1 { padding: 0 15px; font-size: 1.7em; + font-weight: bold; @media screen and (min-width: 30em) { padding: 0 40px; font-size: 2em; diff --git a/src/components/ContentHeader.tsx b/src/components/ContentHeader.tsx index 473cd4cd9..f80174e5b 100644 --- a/src/components/ContentHeader.tsx +++ b/src/components/ContentHeader.tsx @@ -6,12 +6,10 @@ type Props = { title: string; }; -function ContentHeader({ title }: Props) { +export function ContentHeader({ title }: Props) { return (

{title}

); } - -export default React.memo(ContentHeader); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx deleted file mode 100644 index f95c58171..000000000 --- a/src/components/ErrorBoundary.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; - -// import { getSentry } from '../misc/sentry'; -import { deriveMessageFromError, Err } from '../misc/errors'; -import ErrorBoundaryFallback from './ErrorBoundaryFallback'; - -type Props = { - children: React.ReactNode; -}; - -type State = { - error?: Err; -}; - -class ErrorBoundary extends React.Component { - state = { error: null }; - - static getDerivedStateFromError(error: Err) { - return { error }; - } - - render() { - if (this.state.error) { - const { message, detail } = deriveMessageFromError(this.state.error); - return ; - } else { - return this.props.children; - } - } -} - -export default ErrorBoundary; diff --git a/src/components/Field.module.scss b/src/components/Field.module.scss index 81c2440cd..81dc33b91 100644 --- a/src/components/Field.module.scss +++ b/src/components/Field.module.scss @@ -15,7 +15,7 @@ font-size: inherit; height: 40px; outline: none; - padding: 0 4px; + padding: 0; width: 100%; &:focus { border-color: var(--color-focus-blue); @@ -24,20 +24,20 @@ label { position: absolute; - left: 5px; + left: 0; bottom: 22px; transition: transform 150ms ease-in-out; transform-origin: 0 0; font-size: 0.9em; &.floatAbove { - transform: scale(0.75) translateY(-25px); + transform: scale(0.9) translateY(-25px); } } input { &:focus + label { color: var(--color-focus-blue); - transform: scale(0.75) translateY(-25px); + transform: scale(0.9) translateY(-25px); } } } diff --git a/src/components/Home.tsx b/src/components/Home.tsx index d7ddbabf1..831e33e62 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -1,7 +1,7 @@ import React, { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; -import ContentHeader from './ContentHeader'; +import { ContentHeader } from './ContentHeader'; import s0 from './Home.module.scss'; import Loading from './Loading'; import TrafficChart from './TrafficChart'; diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx index 403bdc33d..e8da86a20 100644 --- a/src/components/Logs.tsx +++ b/src/components/Logs.tsx @@ -5,7 +5,7 @@ import { Pause, Play } from 'react-feather'; import { useTranslation } from 'react-i18next'; import { areEqual, FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from 'src/api/logs'; -import ContentHeader from 'src/components/ContentHeader'; +import { ContentHeader } from 'src/components/ContentHeader'; import LogSearch from 'src/components/LogSearch'; import { connect } from 'src/components/StateProvider'; import SvgYacd from 'src/components/SvgYacd'; diff --git a/src/components/Root.scss b/src/components/Root.scss index eaa180945..f2ed082ef 100644 --- a/src/components/Root.scss +++ b/src/components/Root.scss @@ -1,6 +1,6 @@ -.relative { - position: relative; -} +@tailwind base; +@tailwind components; +@tailwind utilities; .border-left, .border-top, @@ -212,12 +212,15 @@ body { border-radius: 4px; } -.fixed { - position: fixed; -} -.left-0 { - left: 0; -} -.bottom-0 { - bottom: 0; -} +// .relative { +// position: relative; +// } +// .fixed { +// position: fixed; +// } +// .left-0 { +// left: 0; +// } +// .bottom-0 { +// bottom: 0; +// } diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 9d05a48ca..997f5da6c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import cx from 'clsx'; import { useAtom } from 'jotai'; import * as React from 'react'; +import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary'; import { RouteObject } from 'react-router'; import { HashRouter as Router, useRoutes } from 'react-router-dom'; import { Toaster } from 'sonner'; @@ -13,12 +14,14 @@ import { Head } from 'src/components/shared/Head'; import { queryClient } from 'src/misc/query'; import { AppConfigSideEffect } from '$src/components/fn/AppConfigSideEffect'; +import { ENDPOINT } from '$src/misc/constants'; import { darkModePureBlackToggleAtom } from '$src/store/app'; import { actions, initialState } from '../store'; import { Backend } from './backend/Backend'; import { MutableConnRefCtx } from './conns/ConnCtx'; -import ErrorBoundary from './ErrorBoundary'; +import { ErrorFallback } from './error/ErrorFallback'; +import { BackendBeacon } from './fn/BackendBeacon'; import Home from './Home'; import Loading2 from './Loading2'; import s0 from './Root.module.scss'; @@ -32,7 +35,7 @@ const Config = lazy(() => import('./Config')); const Logs = lazy(() => import('./Logs')); const Proxies = lazy(() => import('./proxies/Proxies')); const Rules = lazy(() => import('./Rules')); -const StyleGuide = lazy(() => import('$src/components/style/StyleGuide')) +const StyleGuide = lazy(() => import('$src/components/style/StyleGuide')); const routes = [ { path: '/', element: }, @@ -58,14 +61,15 @@ function RouteInnerApp() { function SideBarApp() { return ( - <> +
+
}>
- +
); } @@ -78,7 +82,7 @@ function App() { function AppShell({ children }: { children: React.ReactNode }) { const [pureBlackDark] = useAtom(darkModePureBlackToggleAtom); - const clazz = cx(s0.app, { pureBlackDark }); + const clazz = cx({ pureBlackDark }); return ( <> @@ -87,22 +91,26 @@ function AppShell({ children }: { children: React.ReactNode }) { ); } +const onErrorReset: ErrorBoundaryProps['onReset'] = (_details) => { + queryClient.invalidateQueries([ENDPOINT.config]); +}; + const Root = () => ( - + - - - + + + - }> + }> - - + + - + ); export default Root; diff --git a/src/components/Rules.tsx b/src/components/Rules.tsx index 4622d0964..e691c5dd7 100644 --- a/src/components/Rules.tsx +++ b/src/components/Rules.tsx @@ -11,7 +11,7 @@ import { ruleFilterTextAtom } from '$src/store/rules'; import { ClashAPIConfig, RuleType } from '$src/types'; import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; -import ContentHeader from './ContentHeader'; +import { ContentHeader } from './ContentHeader'; import Rule from './Rule'; import s from './Rules.module.scss'; diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index ed94581f8..75493b1f0 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -42,36 +42,12 @@ interface SideBarRowProps { } const pages = [ - { - to: '/', - iconId: 'activity', - labelText: 'Overview', - }, - { - to: '/proxies', - iconId: 'globe', - labelText: 'Proxies', - }, - { - to: '/rules', - iconId: 'command', - labelText: 'Rules', - }, - { - to: '/connections', - iconId: 'link', - labelText: 'Conns', - }, - { - to: '/configs', - iconId: 'settings', - labelText: 'Config', - }, - { - to: '/logs', - iconId: 'file', - labelText: 'Logs', - }, + { to: '/', iconId: 'activity', labelText: 'Overview' }, + { to: '/proxies', iconId: 'globe', labelText: 'Proxies' }, + { to: '/rules', iconId: 'command', labelText: 'Rules' }, + { to: '/connections', iconId: 'link', labelText: 'Conns' }, + { to: '/configs', iconId: 'settings', labelText: 'Config' }, + { to: '/logs', iconId: 'file', labelText: 'Logs' }, ]; export default function SideBar() { diff --git a/src/components/SvgGithub.tsx b/src/components/SvgGithub.tsx deleted file mode 100644 index 45828c270..000000000 --- a/src/components/SvgGithub.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -type Props = { - width?: number; - height?: number; -}; - -export default function SvgGithub({ width = 24, height = 24 }: Props = {}) { - return ( - - - - ); -} diff --git a/src/components/about/About.module.scss b/src/components/about/About.module.scss index 0ac9b8a3e..a9b9e68b1 100644 --- a/src/components/about/About.module.scss +++ b/src/components/about/About.module.scss @@ -3,6 +3,10 @@ @media screen and (min-width: 30em) { padding: 10px 40px; } + + p { + margin: 5px 0; + } } .mono { @@ -12,7 +16,10 @@ .link { color: var(--color-text-secondary); display: inline-flex; + gap: 5px; + align-items: center; } + .link:hover { color: var(--color-text-highlight); } diff --git a/src/components/about/About.tsx b/src/components/about/About.tsx index 44e09b26d..1716fc34b 100644 --- a/src/components/about/About.tsx +++ b/src/components/about/About.tsx @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import * as React from 'react'; -import { GitHub } from 'react-feather'; import { fetchVersion } from 'src/api/version'; -import ContentHeader from 'src/components/ContentHeader'; +import { ContentHeader } from 'src/components/ContentHeader'; import { useApiConfig } from 'src/store/app'; +import { GitHubIcon } from '../icon/GitHubIcon'; import s from './About.module.scss'; function Version({ name, link, version }: { name: string; link: string; version: string }) { @@ -17,7 +17,7 @@ function Version({ name, link, version }: { name: string; link: string; version:

- + Source

@@ -27,9 +27,7 @@ function Version({ name, link, version }: { name: string; link: string; version: export function About() { const apiConfig = useApiConfig(); - const { data: version } = useQuery(['/version', apiConfig], () => - fetchVersion('/version', apiConfig), - ); + const { data: version } = useQuery(['/version', apiConfig], fetchVersion); return ( <> diff --git a/src/components/backend/Backend.tsx b/src/components/backend/Backend.tsx index 9aef7812b..a0e249415 100644 --- a/src/components/backend/Backend.tsx +++ b/src/components/backend/Backend.tsx @@ -2,22 +2,21 @@ import React from 'react'; import { BackendList } from '$src/components/backend/BackendList'; +import { Sep } from '../shared/Basic'; import { ThemeSwitcher } from '../shared/ThemeSwitcher'; import { BackendForm } from './BackendForm'; export function Backend() { return ( -
- - - -
- +
+
+ + + +
+ +
); } - -function Sep() { - return
; -} diff --git a/src/components/backend/BackendForm.module.scss b/src/components/backend/BackendForm.module.scss index 8d9cc1116..93e386fcd 100644 --- a/src/components/backend/BackendForm.module.scss +++ b/src/components/backend/BackendForm.module.scss @@ -2,6 +2,7 @@ display: flex; justify-content: center; align-items: center; + padding: 15px; .icon { --stroke: var(--color-text-secondary); @@ -37,6 +38,7 @@ height: 20px; font-size: 0.8em; color: #ff8b8b; + margin-bottom: 5px; } .footer { diff --git a/src/components/backend/BackendForm.tsx b/src/components/backend/BackendForm.tsx index b68217641..e578a003a 100644 --- a/src/components/backend/BackendForm.tsx +++ b/src/components/backend/BackendForm.tsx @@ -5,13 +5,12 @@ import { fetchConfigs } from '$src/api/configs'; import Field from '$src/components//Field'; import Button from '$src/components/Button'; import SvgYacd from '$src/components/SvgYacd'; -import { noop } from '$src/misc/utils'; import { clashAPIConfigsAtom, findClashAPIConfigIndex } from '$src/store/app'; import { ClashAPIConfig } from '$src/types'; import s0 from './BackendForm.module.scss'; -const { useState, useRef, useCallback, useEffect } = React; +const { useState, useRef, useCallback } = React; const Ok = 0; export function BackendForm() { @@ -63,19 +62,6 @@ export function BackendForm() { [apiConfigs, baseURL, metaLabel, secret, setApiConfigs], ); - const detectApiServer = async () => { - // if there is already a clash API server at `/`, just use it as default value - const res = await fetch('/'); - res.json().then((data) => { - if (data['hello'] === 'clash') { - setBaseURL(window.location.origin); - } - }, noop); - }; - useEffect(() => { - detectApiServer(); - }, []); - return (
diff --git a/src/components/backend/BackendList.tsx b/src/components/backend/BackendList.tsx index 1e7a4d423..5aa0b2cab 100644 --- a/src/components/backend/BackendList.tsx +++ b/src/components/backend/BackendList.tsx @@ -8,7 +8,7 @@ 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 { sleep } from '$src/misc/utils'; import { clashAPIConfigsAtom, findClashAPIConfigIndex, @@ -18,8 +18,6 @@ import { ClashAPIConfig } from '$src/types'; import s from './BackendList.module.scss'; -const PASS_THRU_ERROR = {}; - export function BackendList() { const navigate = useNavigate(); const [apiConfigs, setApiConfigs] = useAtom(clashAPIConfigsAtom); @@ -45,44 +43,40 @@ export function BackendList() { 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); + let res: Response; + try { + res = await req(url, init); + } catch (err) { + console.log(err); + toast.error('Failed to connect'); + return; + } + let data: { hello: unknown }; + try { + data = await res.json(); + } catch (err) { + console.log(err); + toast.error('Unexpected response'); + return; + } + if (typeof data['hello'] !== 'string') { + console.log('Response:', data); + toast.error('Unexpected response'); + return; + } + if (currIdx === idx) { + navigate('/', { replace: true }); + } else { + setCurrIdx(idx); + await sleep(32); + // manual clean up is too complex + // we just reload the app + try { + window.location.href = '/'; + } catch (err) { + // ignore + } + } }, [apiConfigs, currIdx, setCurrIdx, navigate], ); diff --git a/src/components/error/BackendErrorFallback.tsx b/src/components/error/BackendErrorFallback.tsx new file mode 100644 index 000000000..5ef4161f5 --- /dev/null +++ b/src/components/error/BackendErrorFallback.tsx @@ -0,0 +1,93 @@ +import React, { useCallback } from 'react'; +import type { FallbackProps } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { SimplifiedResponse } from '$src/api/fetch'; +import { FetchCtx } from '$src/types'; + +import Button from '../Button'; +import { Sep } from '../shared/Basic'; +import { ErrorFallbackLayout } from './ErrorFallbackLayout'; + +function useStuff(resetErrorBoundary: FallbackProps['resetErrorBoundary']) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const onClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + resetErrorBoundary(); + navigate('/backend'); + }, + [navigate, resetErrorBoundary], + ); + return { t, onClick }; +} + +export function FetchNetworkErrorFallback(props: { + ctx: FetchCtx; + resetErrorBoundary: FallbackProps['resetErrorBoundary']; +}) { + const { resetErrorBoundary, ctx } = props; + const { t, onClick } = useStuff(resetErrorBoundary); + return ( + +

Failed to connect to the backend {ctx.apiConfig.baseURL}

+ + +
+ ); +} + +export function BackendUnauthorizedErrorFallback(props: { + ctx: FetchCtx; + resetErrorBoundary: FallbackProps['resetErrorBoundary']; +}) { + const { resetErrorBoundary, ctx } = props; + const { t, onClick } = useStuff(resetErrorBoundary); + return ( + +

Unauthorized to connect to the backend {ctx.apiConfig.baseURL}

+ {ctx.apiConfig.secret ? ( +

You might using a wrong secret

+ ) : ( +

You probably need to provide a secret

+ )} + + +
+ ); +} + +export function BackendGeneralErrorFallback(props: { + ctx: FetchCtx & { response: SimplifiedResponse }; + resetErrorBoundary: FallbackProps['resetErrorBoundary']; +}) { + const { resetErrorBoundary, ctx } = props; + const { t, onClick } = useStuff(resetErrorBoundary); + const { response } = ctx; + return ( + +

Unexpected response from the backend {ctx.apiConfig.baseURL}

+ + + +
+

Response Status

+

{response.status}

+

Response Headers

+
    + {response.headers.map((h) => { + return
  • {h}
  • ; + })} +
+ {response.data ? ( + <> +

Response Body

+
{response.data}
+ + ) : null} +
+
+ ); +} diff --git a/src/components/error/ErrorBoundaryFallback.module.scss b/src/components/error/ErrorBoundaryFallback.module.scss new file mode 100644 index 000000000..2cace7e01 --- /dev/null +++ b/src/components/error/ErrorBoundaryFallback.module.scss @@ -0,0 +1,14 @@ +.link { + display: inline-flex; + align-items: center; + + color: var(--color-text-secondary); + &:hover, + &:active { + color: #387cec; + } + + svg { + margin-right: 5px; + } +} diff --git a/src/components/ErrorBoundaryFallback.tsx b/src/components/error/ErrorBoundaryFallback.tsx similarity index 53% rename from src/components/ErrorBoundaryFallback.tsx rename to src/components/error/ErrorBoundaryFallback.tsx index 7ab7d0662..2e76e98fa 100644 --- a/src/components/ErrorBoundaryFallback.tsx +++ b/src/components/error/ErrorBoundaryFallback.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import s0 from './ErrorBoundaryFallback.module.scss'; -import SvgGithub from './SvgGithub'; -import SvgYacd from './SvgYacd'; +import { ErrorFallbackLayout } from '$src/components/error/ErrorFallbackLayout'; + +import { GitHubIcon } from '../icon/GitHubIcon'; +import sx from './ErrorBoundaryFallback.module.scss'; const yacdRepoIssueUrl = 'https://github.com/haishanh/yacd/issues'; type Props = { @@ -12,19 +13,16 @@ type Props = { function ErrorBoundaryFallback({ message, detail }: Props) { return ( -
-
- -
+ {message ?

{message}

: null} {detail ?

{detail}

: null}

- - + + haishanh/yacd

-
+ ); } diff --git a/src/components/error/ErrorFallback.tsx b/src/components/error/ErrorFallback.tsx new file mode 100644 index 000000000..3c9ddb2f8 --- /dev/null +++ b/src/components/error/ErrorFallback.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { FallbackProps } from 'react-error-boundary'; + +import { + deriveMessageFromError, + YacdBackendGeneralError, + YacdBackendUnauthorizedError, + YacdFetchNetworkError, +} from '$src/misc/errors'; + +import { + BackendGeneralErrorFallback, + BackendUnauthorizedErrorFallback, + FetchNetworkErrorFallback, +} from './BackendErrorFallback'; +import ErrorBoundaryFallback from './ErrorBoundaryFallback'; + +export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + if (error instanceof YacdFetchNetworkError) { + return ; + } + + if (error instanceof YacdBackendUnauthorizedError) { + return ( + + ); + } + + if (error instanceof YacdBackendGeneralError) { + return ; + } + + const { message, detail } = deriveMessageFromError(error); + return ; +} diff --git a/src/components/ErrorBoundaryFallback.module.scss b/src/components/error/ErrorFallbackLayout.module.scss similarity index 51% rename from src/components/ErrorBoundaryFallback.module.scss rename to src/components/error/ErrorFallbackLayout.module.scss index 6133568da..b69580241 100644 --- a/src/components/ErrorBoundaryFallback.module.scss +++ b/src/components/error/ErrorFallbackLayout.module.scss @@ -6,32 +6,16 @@ right: 0; overflow: hidden; - padding: 20px; + padding: 15px; background: var(--color-background); color: var(--color-text); text-align: center; } .yacd { - color: #2a477a; - opacity: 0.6; + color: #20497e; + opacity: 0.4; display: flex; justify-content: center; align-items: center; - padding: 40px; -} - -.link { - display: inline-flex; - align-items: center; - - color: var(--color-text-secondary); - &:hover, - &:active { - color: #387cec; - } - - svg { - margin-right: 5px; - } } diff --git a/src/components/error/ErrorFallbackLayout.tsx b/src/components/error/ErrorFallbackLayout.tsx new file mode 100644 index 000000000..2ab2872f5 --- /dev/null +++ b/src/components/error/ErrorFallbackLayout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import SvgYacd from '../SvgYacd'; +import sx from './ErrorFallbackLayout.module.scss'; + +export function ErrorFallbackLayout(props: { children: React.ReactNode }) { + return ( +
+
+ +
+ {props.children} +
+ ); +} diff --git a/src/components/fn/BackendBeacon.tsx b/src/components/fn/BackendBeacon.tsx new file mode 100644 index 000000000..c0aab448e --- /dev/null +++ b/src/components/fn/BackendBeacon.tsx @@ -0,0 +1,16 @@ +import React, { Suspense } from 'react'; + +import { useClashConfig } from '$src/store/configs'; + +export function BackendBeacon() { + return ( + + + + ); +} + +function BackendBeaconCore() { + useClashConfig(); + return null; +} diff --git a/src/components/form/Toggle.module.scss b/src/components/form/Toggle.module.scss index ae9dcc0a2..30675290d 100644 --- a/src/components/form/Toggle.module.scss +++ b/src/components/form/Toggle.module.scss @@ -46,7 +46,6 @@ background-color 0.15s; background: var(--bg-toggle-track); border-radius: 100px; - border-width: 1px; &:before { position: absolute; content: ''; diff --git a/src/components/icon/GitHubIcon.tsx b/src/components/icon/GitHubIcon.tsx new file mode 100644 index 000000000..2b5d8237b --- /dev/null +++ b/src/components/icon/GitHubIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export function GitHubIcon(props: { width?: number; height?: number; size?: number }) { + const width = props.width || props.size || 16; + const height = props.height || props.size || 16; + return ( + + + + ); +} diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx index ebbec3379..0d0fe08b1 100644 --- a/src/components/proxies/Proxies.tsx +++ b/src/components/proxies/Proxies.tsx @@ -2,7 +2,7 @@ import { Tooltip } from '@reach/tooltip'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import Button from 'src/components/Button'; -import ContentHeader from 'src/components/ContentHeader'; +import { ContentHeader } from 'src/components/ContentHeader'; import { ClosePrevConns } from 'src/components/proxies/ClosePrevConns'; import { ProxyGroup } from 'src/components/proxies/ProxyGroup'; import { ProxyPageFab } from 'src/components/proxies/ProxyPageFab'; diff --git a/src/components/proxies/ProxyProviderList.tsx b/src/components/proxies/ProxyProviderList.tsx index 754eeace6..ab4553194 100644 --- a/src/components/proxies/ProxyProviderList.tsx +++ b/src/components/proxies/ProxyProviderList.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import ContentHeader from 'src/components/ContentHeader'; +import { ContentHeader } from 'src/components/ContentHeader'; import { ProxyProvider } from 'src/components/proxies/ProxyProvider'; import { FormattedProxyProvider } from 'src/store/types'; diff --git a/src/components/rules/rules.hooks.tsx b/src/components/rules/rules.hooks.tsx index c2043ebcf..f5d275287 100644 --- a/src/components/rules/rules.hooks.tsx +++ b/src/components/rules/rules.hooks.tsx @@ -55,17 +55,12 @@ export function useInvalidateQueries() { } export function useRuleProviderQuery(apiConfig: ClashAPIConfig) { - return useQuery(['/providers/rules', apiConfig], () => - fetchRuleProviders('/providers/rules', apiConfig), - ); + return useQuery(['/providers/rules', apiConfig], fetchRuleProviders); } export function useRuleAndProvider(apiConfig: ClashAPIConfig) { - const { data: rules, isFetching } = useQuery(['/rules', apiConfig], () => - fetchRules('/rules', apiConfig), - ); + const { data: rules, isFetching } = useQuery(['/rules', apiConfig], fetchRules); const { data: provider } = useRuleProviderQuery(apiConfig); - const [filterText] = useAtom(ruleFilterTextAtom); if (filterText === '') { return { rules, provider, isFetching }; diff --git a/src/components/shared/Basic.module.scss b/src/components/shared/Basic.module.scss index 0c4a076b1..fcd545b5b 100644 --- a/src/components/shared/Basic.module.scss +++ b/src/components/shared/Basic.module.scss @@ -1,6 +1,7 @@ h2.sectionNameType { margin: 0; font-size: 1.3em; + font-weight: bold; @media screen and (min-width: 30em) { font-size: 1.5em; } diff --git a/src/components/shared/Basic.tsx b/src/components/shared/Basic.tsx index 58432cc7f..e962e73d3 100644 --- a/src/components/shared/Basic.tsx +++ b/src/components/shared/Basic.tsx @@ -14,3 +14,7 @@ export function SectionNameType({ name, type }: { name: string; type: string }) export function LoadingDot() { return ; } + +export function Sep(props: { height?: number }) { + return
; +} diff --git a/src/misc/constants.ts b/src/misc/constants.ts new file mode 100644 index 000000000..5eed486de --- /dev/null +++ b/src/misc/constants.ts @@ -0,0 +1,3 @@ +export const ENDPOINT = { + config: '/configs', +}; diff --git a/src/misc/errors.ts b/src/misc/errors.ts index 14a739d59..2a2a84528 100644 --- a/src/misc/errors.ts +++ b/src/misc/errors.ts @@ -1,3 +1,6 @@ +import { SimplifiedResponse } from '$src/api/fetch'; +import { ClashAPIConfig } from '$src/types'; + export const DOES_NOT_SUPPORT_FETCH = 0; export class YacdError extends Error { @@ -9,6 +12,33 @@ export class YacdError extends Error { } } +export class YacdFetchNetworkError extends Error { + constructor( + public message: string, + public ctx: { endpoint: string; apiConfig: ClashAPIConfig }, + ) { + super(message); + } +} + +export class YacdBackendUnauthorizedError extends Error { + constructor( + public message: string, + public ctx: { endpoint: string; apiConfig: ClashAPIConfig }, + ) { + super(message); + } +} + +export class YacdBackendGeneralError extends Error { + constructor( + public message: string, + public ctx: { endpoint: string; apiConfig: ClashAPIConfig; response: SimplifiedResponse }, + ) { + super(message); + } +} + export const errors = { [DOES_NOT_SUPPORT_FETCH]: { message: 'Browser not supported!', diff --git a/src/misc/utils.ts b/src/misc/utils.ts index 1b3e7c1c3..6d03df95b 100644 --- a/src/misc/utils.ts +++ b/src/misc/utils.ts @@ -36,3 +36,7 @@ export function pad0(number: number | string, len: number): string { // eslint-disable-next-line @typescript-eslint/no-empty-function export const noop = () => {}; + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/store/configs.ts b/src/store/configs.ts index af513bf7f..b594ed8ab 100644 --- a/src/store/configs.ts +++ b/src/store/configs.ts @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import * as configsAPI from '$src/api/configs'; +import { fetchConfigs2 } from '$src/api/configs'; +import { ENDPOINT } from '$src/misc/constants'; import { useApiConfig } from '$src/store/app'; export function useClashConfig() { const apiConfig = useApiConfig(); - return useQuery(['/configs', apiConfig] as const, configsAPI.fetchConfigs2); + return useQuery([ENDPOINT.config, apiConfig], fetchConfigs2); } diff --git a/src/types.ts b/src/types.ts index bf662a2bd..716ff1186 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,3 +10,8 @@ export type ClashAPIConfig = { export type LogsAPIConfig = ClashAPIConfig & { logLevel: string }; export type RuleType = { id?: number; type?: string; payload?: string; proxy?: string }; + +export type FetchCtx = { + endpoint: string; + apiConfig: ClashAPIConfig; +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 000000000..d37737fc0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} +