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 (
-
+
);
}
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: [],
+}
+